Compare commits

..
Author SHA1 Message Date
jialin a19fea0c6c fix(usage): carry full filter set in breakdown tables and fix double fetch
- breakdown sub-tables now send all active filters (route/user/api_key), matching the trend chart
- summary tab filters the token trend by user and unions user options from both meta APIs (deduped by id)
- stabilize the filters reference so meta load no longer retriggers a second fetch on mount
2026-06-30 16:57:17 +08:00
118 changed files with 1318 additions and 734 deletions
-18
View File
@@ -1,7 +1,6 @@
name: CI name: CI
on: on:
workflow_dispatch: {}
push: push:
branches: branches:
- 'main' - 'main'
@@ -118,20 +117,3 @@ jobs:
remote_path: releases/${{ steps.version.outputs.version }}.tar.gz remote_path: releases/${{ steps.version.outputs.version }}.tar.gz
accelerate: true accelerate: true
clean: false clean: false
trigger-backend:
needs: build-publish
if: (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-22.04
steps:
- name: Dispatch backend build
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.DISPATCH_PAT }}
repository: gpustack/gpustack
event-type: ui-built
client-payload: |
{
"ref": "${{ github.ref }}",
"sha": "${{ github.sha }}"
}
-11
View File
@@ -88,16 +88,6 @@ Prefer action-driven updates, explicit handlers, and localized state transitions
Existing `styled-components` usage is legacy tech debt — do not migrate it wholesale, but do not add new `styled-components` either. Theme tokens (`var(--ant-color-*)`) work in all three approaches. Existing `styled-components` usage is legacy tech debt — do not migrate it wholesale, but do not add new `styled-components` either. Theme tokens (`var(--ant-color-*)`) work in all three approaches.
## Layout
Compose layout with Ant components, not hand-written `display: flex`.
- **1D flex** (row/column with `gap`, `align`, `justify`) → `Flex`. Do not write raw `display: flex` in new code.
- **Inline sequence** of a few elements with uniform spacing → `Space`.
- **Page/grid columns** → `Row` / `Col`.
Drive spacing with the theme scale (`Flex`/`Space` `gap`, or `var(--ant-*)` spacing tokens), not scattered `px` literals.
# Naming conventions # Naming conventions
A page module lives under `src/pages/{module}` with this sub-structure: `components/`, `config/`, `forms/`, `hooks/`, `services/`, `index.tsx`. File naming: A page module lives under `src/pages/{module}` with this sub-structure: `components/`, `config/`, `forms/`, `hooks/`, `services/`, `index.tsx`. File naming:
@@ -111,7 +101,6 @@ A page module lives under `src/pages/{module}` with this sub-structure: `compone
- `config/types.ts` — TypeScript types. Form shape → `FormData`; table/list row → `ListItem`. - `config/types.ts` — TypeScript types. Form shape → `FormData`; table/list row → `ListItem`.
- `config/index.ts` — static constants, enums, and value/label maps (e.g. `XxxStatusValueMap`, `XxxStatusLabelMap`). Keep constants out of `types.ts`. - `config/index.ts` — static constants, enums, and value/label maps (e.g. `XxxStatusValueMap`, `XxxStatusLabelMap`). Keep constants out of `types.ts`.
- **`Select` options that need i18n**: set `label` to the message key and add `locale: true` on the option — the field translates it at render. Omit `locale` for options whose label is already final text. Ref `src/pages/benchmark/config/index.ts`.
# Common components # Common components
+2 -1
View File
@@ -17,7 +17,7 @@
"@ant-design/pro-components": "3.1.0-0", "@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51", "@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1", "@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.35", "@gpustack/core-ui": "^1.0.32",
"@huggingface/gguf": "^0.1.7", "@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1", "@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6", "@huggingface/tasks": "^0.11.6",
@@ -39,6 +39,7 @@
"culori": "^4.0.2", "culori": "^4.0.2",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"driver.js": "^1.3.1",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"has-ansi": "^5.0.1", "has-ansi": "^5.0.1",
+13 -5
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1 specifier: ^7.1.1
version: 7.1.2 version: 7.1.2
'@gpustack/core-ui': '@gpustack/core-ui':
specifier: ^1.0.35 specifier: ^1.0.32
version: 1.0.35(czdvzceysqw7iv6pct2ucnb23e) version: 1.0.32(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf': '@huggingface/gguf':
specifier: ^0.1.7 specifier: ^0.1.7
version: 0.1.18 version: 0.1.18
@@ -89,6 +89,9 @@ importers:
dompurify: dompurify:
specifier: ^3.2.6 specifier: ^3.2.6
version: 3.4.2 version: 3.4.2
driver.js:
specifier: ^1.3.1
version: 1.4.0
echarts: echarts:
specifier: ^5.5.1 specifier: ^5.5.1
version: 5.6.0 version: 5.6.0
@@ -1481,8 +1484,8 @@ packages:
resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz} resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz}
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
'@gpustack/core-ui@1.0.35': '@gpustack/core-ui@1.0.32':
resolution: {integrity: sha512-MaEmCM3FikeKZdUgDSyUtIfXtNMbNOLeFiTynl6DcKEraUOpk6tgQXPDginBY3uXuisG4hzoIQbpHHkzaYiQew==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.35.tgz} resolution: {integrity: sha512-kGTazoqbK2KyZgOP6gmQaRxTiQVfF2IKLGDXjJq6w6BbmJgALXFJA2v2ROjAbjEVyfTdBzyYeXfPo/JgISpMNw==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.32.tgz}
peerDependencies: peerDependencies:
'@ant-design/icons': ^6.1.0 '@ant-design/icons': ^6.1.0
'@ant-design/pro-components': 3.1.0-0 '@ant-design/pro-components': 3.1.0-0
@@ -4317,6 +4320,9 @@ packages:
dot-case@3.0.4: dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==, tarball: https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz} resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==, tarball: https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz}
driver.js@1.4.0:
resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==, tarball: https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz}
duck@0.1.12: duck@0.1.12:
resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==, tarball: https://registry.npmjs.org/duck/-/duck-0.1.12.tgz} resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==, tarball: https://registry.npmjs.org/duck/-/duck-0.1.12.tgz}
@@ -10802,7 +10808,7 @@ snapshots:
'@formatjs/intl-utils@2.3.0': {} '@formatjs/intl-utils@2.3.0': {}
'@gpustack/core-ui@1.0.35(czdvzceysqw7iv6pct2ucnb23e)': '@gpustack/core-ui@1.0.32(czdvzceysqw7iv6pct2ucnb23e)':
dependencies: dependencies:
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -14548,6 +14554,8 @@ snapshots:
no-case: 3.0.4 no-case: 3.0.4
tslib: 2.8.1 tslib: 2.8.1
driver.js@1.4.0: {}
duck@0.1.12: duck@0.1.12:
dependencies: dependencies:
underscore: 1.13.8 underscore: 1.13.8

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

-12
View File
@@ -1,12 +0,0 @@
// Wrapper around core-ui's FullMarkdown that co-locates the KaTeX stylesheet.
//
// core-ui deliberately does NOT bundle katex.min.css (importing it there
// base64-inlines ~1.4MB of fonts into the shared, render-blocking index.css).
// Importing it here keeps the KaTeX CSS in the route chunk that actually
// renders math, so it loads lazily and never blocks first paint.
//
// Always import FullMarkdown from this module, not from '@gpustack/core-ui/markdown'.
import { FullMarkdown } from '@gpustack/core-ui/markdown';
import 'katex/dist/katex.min.css';
export default FullMarkdown;
-1
View File
@@ -28,7 +28,6 @@ export default {
rowSelectedBg: 'transparent', rowSelectedBg: 'transparent',
headerSortActiveBg: 'transparent', headerSortActiveBg: 'transparent',
headerSortHoverBg: 'transparent', headerSortHoverBg: 'transparent',
bodySortBg: 'transparent',
headerBg: 'none' headerBg: 'none'
}, },
Button: { Button: {
-1
View File
@@ -31,7 +31,6 @@ export default {
rowSelectedBg: 'transparent', rowSelectedBg: 'transparent',
headerSortActiveBg: 'transparent', headerSortActiveBg: 'transparent',
headerSortHoverBg: 'transparent', headerSortHoverBg: 'transparent',
bodySortBg: 'transparent',
headerSplitColor: '#e8e8e8', headerSplitColor: '#e8e8e8',
headerBg: 'none' headerBg: 'none'
}, },
+10
View File
@@ -0,0 +1,10 @@
export default function useActions<T>(actions: Global.ActionItem<T>[], ctx: T) {
return actions
.filter((action) => {
return action.visible ? action.visible(ctx) : true;
})
.map((action) => ({
...action,
disabled: action.disabled?.(ctx)
}));
}
+58
View File
@@ -0,0 +1,58 @@
import { useIntl } from '@umijs/max';
import { message } from 'antd';
type MessageType = 'input' | 'select';
const useAppUtils = () => {
const intl = useIntl();
const [messageApi, contextHolder] = message.useMessage();
/**
*
* @param type Array<'input' | 'select'>
* @param name
* @param locale boolean
* @returns
*/
const getRuleMessage = (
type: MessageType | MessageType[],
name: string,
locale = true
) => {
const nameStr = locale ? intl.formatMessage({ id: name }) : name;
// transform type to array
const typeList = Array.isArray(type) ? type : [type];
if (typeList.includes('select') && typeList.includes('input')) {
return intl.formatMessage(
{ id: 'common.form.rule.selectInput' },
{ name: nameStr }
);
}
if (typeList.includes('input')) {
return intl.formatMessage(
{ id: 'common.form.rule.input' },
{ name: nameStr }
);
}
return intl.formatMessage(
{ id: 'common.form.rule.select' },
{ name: nameStr }
);
};
const showSuccess = (msg?: string) => {
messageApi.success(
msg || intl.formatMessage({ id: 'common.message.success' })
);
};
return {
getRuleMessage,
showSuccess
};
};
export default useAppUtils;
+18
View File
@@ -0,0 +1,18 @@
// broadcast channel hook
import { useEffect, useRef } from 'react';
export const useBroadcast = () => {
const broadcastChannel = useRef<BroadcastChannel | null>(null);
useEffect(() => {
broadcastChannel.current = new BroadcastChannel('broadcast_channel');
return () => {
broadcastChannel.current?.close();
console.log('broadcast channel closed');
};
}, []);
return { broadcastChannel };
};
+56
View File
@@ -0,0 +1,56 @@
import _ from 'lodash';
import { useRef } from 'react';
export default function useContainerScroll(
container: any,
options?: { toBottom?: boolean }
) {
const isWheeled = useRef(false);
const scroller = useRef(container);
const optionsRef = useRef(options);
const toBottomFlag = useRef(options?.toBottom);
const timerRef = useRef<any>(null);
const debunceResetWheeled = _.debounce(() => {
isWheeled.current = false;
}, 5000);
const handleContentWheel = (e: any) => {
isWheeled.current = true;
debunceResetWheeled.cancel?.();
debunceResetWheeled();
};
const scrollerRun = () => {
const scrollerContainer = scroller.current?.current || {};
const { scrollHeight, clientHeight, scrollTop } = scrollerContainer;
if (
optionsRef.current?.toBottom &&
toBottomFlag.current &&
scrollHeight > clientHeight + scrollTop
) {
scroller.current.current.scrollTop = scrollHeight;
// toBottomFlag.current = false;
isWheeled.current = false;
} else if (
!isWheeled.current &&
scrollHeight > clientHeight + scrollTop &&
scroller.current?.current
) {
scroller.current.current.scrollTop += 10;
window.requestAnimationFrame(scrollerRun);
}
};
const updateScrollerPosition = () => {
if (!isWheeled.current) {
window.requestAnimationFrame(scrollerRun);
}
};
return {
handleContentWheel,
updateScrollerPosition,
scroller
};
}
+23
View File
@@ -0,0 +1,23 @@
import { useCallback, useState } from 'react';
const useCopyToClipboard = () => {
const [copied, setCopied] = useState(false);
const copyToClipboard = useCallback(async (text: string) => {
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
}
} catch (error) {
setCopied(false);
}
}, []);
return { copied, copyToClipboard };
};
export default useCopyToClipboard;
+71
View File
@@ -0,0 +1,71 @@
import { HandlerOptions } from '@/hooks/use-chunk-fetch';
import { useDownloadStream } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Progress, notification } from 'antd';
import dayjs from 'dayjs';
const renderMessage = (title: string) => {
return (
<div
style={{
width: 280,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden'
}}
>
{title}
</div>
);
};
const createFileName = (name: string) => {
const timestamp = dayjs().format('YYYY-MM-DD_HH-mm-ss');
const fileName = `${name}_${timestamp}.txt`;
return fileName;
};
const useDownloadLogs = () => {
const { downloadStream } = useDownloadStream();
const intl = useIntl();
const [api, contextHolder] = notification.useNotification({
stack: { threshold: 1 }
});
const downloadNotification = (
data: HandlerOptions & {
filename: string;
duration?: number;
chunkRequestRef: any;
}
) => {
api.open({
duration: data.duration,
message: renderMessage(data.filename),
key: data.filename,
closeIcon: (
<span>{intl.formatMessage({ id: 'common.button.cancel' })}</span>
),
description: <Progress percent={data.percent} size="small"></Progress>,
onClose() {
data.chunkRequestRef?.current?.abort();
notification.destroy?.(data.filename);
}
});
};
const handleDownloadLog = async (params: { url: string; name: string }) => {
downloadStream({
url: params.url,
filename: createFileName(params.name),
downloadNotification
});
};
return {
onDownloadLog: handleDownloadLog,
contextHolder
};
};
export default useDownloadLogs;
+136
View File
@@ -0,0 +1,136 @@
import useSetChunkFetch, { HandlerOptions } from '@/hooks/use-chunk-fetch';
import { message } from 'antd';
import { useEffect, useRef } from 'react';
export default function useDownloadStream() {
const chunkRequestRef = useRef<any>(null);
const logParseWorker = useRef<any>(null);
const clearScreen = useRef(false);
const filename = useRef('log');
const downloadNotificationRef = useRef<any>(null);
const { setChunkFetch } = useSetChunkFetch();
const downloadFile = (content: string) => {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.current;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const updateContent = (data: string, options?: HandlerOptions) => {
const { isComplete, percent } = options || {};
logParseWorker.current?.postMessage({
inputStr: data,
reset: clearScreen.current,
isComplete: isComplete,
percent: percent,
chunked: false
});
clearScreen.current = false;
};
const handleError = (error: any) => {
const errorMsg = error?.message || error;
const msg =
typeof errorMsg === 'string' ? errorMsg : JSON.stringify(errorMsg);
message.error(msg);
downloadNotificationRef.current?.({
duration: 1,
percent: 0,
filename: filename.current
});
};
const downloadStream = async (props: {
data?: any;
url: string;
params?: any;
signal?: AbortSignal;
method?: string;
headers?: any;
filename?: string;
downloadNotification?: (data: any) => void;
}) => {
try {
clearScreen.current = true;
filename.current = props.filename || 'log';
downloadNotificationRef.current = props.downloadNotification;
const { params, url } = props;
chunkRequestRef.current?.current?.abort?.();
chunkRequestRef.current = setChunkFetch({
url,
params,
watch: false,
contentType: 'text',
errorHandler: handleError,
handler: updateContent
});
downloadNotificationRef.current?.({
filename: filename.current,
duration: null,
chunkRequestRef: chunkRequestRef.current
});
} catch (error) {
//
downloadNotificationRef.current?.({
duration: 1,
percent: 0,
filename: filename.current
});
}
};
useEffect(() => {
logParseWorker.current?.terminate?.();
logParseWorker.current = new Worker(
// @ts-ignore
new URL('@/components/logs-viewer/parse-worker.ts', import.meta.url),
{
type: 'module'
}
);
logParseWorker.current.onmessage = (event: any) => {
const { result, isComplete, percent } = event.data;
const isAborted = chunkRequestRef.current?.current?.signal?.aborted;
if (!isComplete && !isAborted) {
downloadNotificationRef.current?.({
percent: percent,
duration: null,
filename: filename.current,
chunkRequestRef: chunkRequestRef.current
});
} else if (isComplete && !isAborted) {
downloadNotificationRef.current?.({
duration: 1,
percent: 100,
filename: filename.current
});
downloadFile(result);
}
};
return () => {
if (logParseWorker.current) {
logParseWorker.current.terminate();
}
};
}, []);
return {
downloadStream
};
}
+35
View File
@@ -0,0 +1,35 @@
import { useIntl } from '@umijs/max';
import { driver, type Config } from 'driver.js';
import { useEffect, useRef } from 'react';
export const useDriver = (config?: Config & { id: string }) => {
const intl = useIntl();
const driverRef = useRef<any>(null);
const handleDoNotShowAgain = () => {};
const init = () => {
driverRef.current = driver({
overlayOpacity: 0.2,
animate: false,
...config
});
};
const start = () => {
if (!driverRef.current) {
init();
}
driverRef.current.drive();
};
useEffect(() => {
return () => {
driverRef.current?.destroy();
};
}, []);
return { start, initDriver: init, driver: driverRef.current };
};
export default useDriver;
+106
View File
@@ -0,0 +1,106 @@
import HotKeys from '@/config/hotkeys';
import { useIntl } from '@umijs/max';
import { createStyles } from 'antd-style';
import { throttle } from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
const useStyles = createStyles(({ css, token }) => ({
hintOverlay: css`
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--color-esc-hint-bg);
color: ${token.colorTextLightSolid};
padding: 16px 24px;
border-radius: 4px;
z-index: 2000;
font-size: 14px;
pointer-events: none;
animation: fadeInOut 2s ease-in-out;
@keyframes fadeInOut {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
opacity: 0;
}
}
`
}));
export function useEscHint(options?: {
enabled?: boolean;
message?: string;
throttleDelay?: number;
}) {
const { enabled = true, message, throttleDelay = 3000 } = options || {};
const intl = useIntl();
const { styles } = useStyles();
const [visible, setVisible] = useState(false);
const timeoutRef = useRef<any>(null);
const isHintActiveRef = useRef(false);
const showHintThrottled = useMemo(
() =>
throttle(
() => {
if (isHintActiveRef.current) return;
isHintActiveRef.current = true;
setVisible(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setVisible(false);
isHintActiveRef.current = false;
}, 2000);
},
throttleDelay,
{
leading: true,
trailing: false
}
),
[throttleDelay]
);
useHotkeys(
HotKeys.ESC,
() => {
if (!enabled) return;
showHintThrottled();
},
{
enabled: enabled
}
);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
showHintThrottled.cancel();
};
}, [showHintThrottled]);
const EscHint = visible ? (
<div className={styles.hintOverlay}>
{message || intl.formatMessage({ id: 'common.tips.escape.disable' })}
</div>
) : null;
return { EscHint };
}
+72
View File
@@ -0,0 +1,72 @@
import qs from 'query-string';
import { useEffect, useRef } from 'react';
export const createEventSourceURL = (url: string) => {
const { host, protocol } = window.location;
return `${protocol}://${host}${url}`;
};
/*
0: connecting
1: connect successfully
2: closed
*/
export default function useEventSource() {
const eventSourceRef = useRef<any>(null);
const createEventSourceConnection = (query: {
url: string;
params: any;
onmessage?: (data: any) => void;
}) => {
eventSourceRef.current?.close?.();
const { url, params, onmessage = () => {} } = query;
const sseurl = createEventSourceURL(url);
eventSourceRef.current = new EventSource(
`${url}?${qs.stringify({
...params
})}`,
{
withCredentials: true
}
);
eventSourceRef.current.onmessage = (res: any) => {
try {
console.log('event source message: ', { res, resData: res });
const data = JSON.parse(res.data);
onmessage(data);
} catch (error) {
// error
console.log('event source error: ', error);
}
};
eventSourceRef.current.onclose = () => {
console.log('event source closed...');
};
eventSourceRef.current.onopen = () => {
console.log('event source connected...');
};
eventSourceRef.current.onerror = (error: any) => {
console.log('event source error: ', error);
};
};
useEffect(() => {
return () => {
eventSourceRef.current?.close?.();
};
}, []);
return {
eventSourceRef: eventSourceRef,
createEventSourceConnection
};
}
+280
View File
@@ -0,0 +1,280 @@
import { useMemoizedFn } from 'ahooks';
import { throttle } from 'lodash';
import {
UseOverlayScrollbarsParams,
useOverlayScrollbars
} from 'overlayscrollbars-react';
import React, { useEffect } from 'react';
import useUserSettings from './use-user-settings';
type OverflowBehavior =
| 'hidden'
| 'scroll'
| 'visible'
| 'visible-hidden'
| 'visible-scroll';
export interface OverlayScrollerOptions {
oppositeTheme?: boolean;
overflow?: {
x?: OverflowBehavior;
y?: OverflowBehavior;
};
scrollbars?: {
theme?: 'os-theme-light' | 'os-theme-dark';
autoHide?: 'never' | 'scroll' | 'leave' | 'move';
autoHideDelay?: number;
clickScroll?: boolean | 'instant';
};
}
export const overlaySollerOptions: UseOverlayScrollbarsParams = {
options: {
update: {
debounce: 0
},
overflow: {
x: 'hidden'
},
scrollbars: {
theme: 'os-theme-light',
autoHide: 'scroll',
autoHideDelay: 600,
clickScroll: 'instant'
}
},
defer: true
};
const RESETSCROLLDELAY = 5000;
/**
*
* @param options.theme: if set theme, it will fix the theme
* @returns
*/
export default function useOverlayScroller(data?: {
options?: OverlayScrollerOptions;
events?: any;
defer?: boolean;
}) {
const { userSettings } = useUserSettings();
const { options, events, defer = true } = data || {};
const { scrollbars, overflow, oppositeTheme } = options || {};
const scrollEventElement = React.useRef<any>(null);
const instanceRef = React.useRef<any>(null);
const initialized = React.useRef(false);
const scrollElementRef = React.useRef<any>(null);
const stopUpdatePosition = React.useRef(false);
const timerRef = React.useRef<any>(null);
const [initialize, instance] = useOverlayScrollbars({
options: {
update: {
debounce: 0
},
overflow: {
x: 'hidden',
...overflow
},
scrollbars: {
autoHide: 'scroll',
autoHideDelay: 600,
clickScroll: 'instant',
...scrollbars,
theme:
scrollbars?.theme ||
(userSettings.theme === 'light' || !userSettings.theme
? 'os-theme-dark'
: 'os-theme-light')
}
},
events: {
...events
},
defer: defer
});
instanceRef.current = instance?.();
scrollEventElement.current =
instanceRef.current?.elements()?.scrollEventElement;
const handleOnScroll = () => {
const scrollTop = scrollEventElement.current?.scrollTop;
const scrollHeight = scrollEventElement.current?.scrollHeight;
const clientHeight = scrollEventElement.current?.clientHeight;
const isBottom = scrollTop + clientHeight + 20 >= scrollHeight;
if (isBottom) {
stopUpdatePosition.current = false;
} else {
stopUpdatePosition.current = true;
}
};
const throttledScroll = useMemoizedFn(
throttle(() => {
scrollEventElement.current?.scrollTo?.({
top: scrollEventElement.current?.scrollHeight,
behavior: 'smooth'
});
instanceRef.current?.update?.();
}, 100)
);
const scrollauto = useMemoizedFn(() => {
scrollEventElement.current?.scrollTo?.({
top: scrollEventElement.current.scrollHeight,
behavior: 'auto'
});
instanceRef.current?.update?.();
});
// scroll to bottom
const throttledUpdateScrollerPosition = useMemoizedFn((delay?: number) => {
if (stopUpdatePosition.current) {
return;
}
if (delay === 0) {
scrollauto();
} else {
throttledScroll();
}
});
// scroll to top
const updateScrollerPositionToTop = useMemoizedFn(() => {
scrollEventElement.current?.scrollTo?.({
top: 0,
behavior: 'auto'
});
instanceRef.current?.update?.();
});
const generateInstance = () => {
instanceRef.current = instance?.();
scrollEventElement.current =
instanceRef.current?.elements()?.scrollEventElement;
};
const handleWheelCallback = useMemoizedFn((e: any) => {
handleOnScroll();
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
stopUpdatePosition.current = false;
}, RESETSCROLLDELAY);
});
// add wheel event
const handleWheelEvent = () => {
scrollElementRef.current?.addEventListener?.('wheel', handleWheelCallback, {
passive: true
});
};
// remove wheel event
const removeWheelEvent = () => {
scrollElementRef.current?.removeEventListener?.(
'wheel',
handleWheelCallback,
{ passive: true }
);
};
const createInstance = useMemoizedFn((el: any) => {
if (instanceRef.current) {
return instanceRef.current;
}
if (el) {
initialize(el);
scrollElementRef.current = el;
initialized.current = true;
instanceRef.current = instance?.();
scrollEventElement.current =
instanceRef.current?.elements()?.scrollEventElement;
handleWheelEvent();
}
return instanceRef.current;
});
const destroyInstance = () => {
instanceRef.current?.destroy?.();
removeWheelEvent();
instanceRef.current = null;
};
const scrollToTarget = (target: any, offset = 100) => {
if (!target) return;
if (!instanceRef.current || !scrollEventElement.current) {
instanceRef.current = instance?.();
scrollEventElement.current =
instanceRef.current?.elements()?.scrollEventElement;
}
const viewport = instanceRef.current?.elements().viewport;
const containerRect = viewport.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const scrollerState = instanceRef.current?.state();
const currentScroll = scrollerState.current?.overflowAmount?.y;
// const currentScroll = instanceRef.current?.scroll().position.y;
const targetPos = targetRect.top - containerRect.top + currentScroll;
scrollEventElement.current.scroll({
y: targetPos - offset,
behavior: 'smooth'
});
instanceRef.current?.update?.();
};
const getScrollElementScrollableHeight = () => {
if (!instanceRef.current || !scrollEventElement.current) {
instanceRef.current = instance?.();
scrollEventElement.current =
instanceRef.current?.elements()?.scrollEventElement;
}
const scrollOffsetElement = instanceRef.current?.elements().viewport;
return {
scrollTop: scrollOffsetElement?.scrollTop,
scrollHeight:
scrollOffsetElement?.scrollHeight - scrollOffsetElement?.clientHeight
};
};
const getScrollElement = () => {
if (!instanceRef.current || !scrollEventElement.current) {
instanceRef.current = instance?.();
scrollEventElement.current =
instanceRef.current?.elements()?.scrollEventElement;
}
return scrollEventElement;
};
useEffect(() => {
return () => {
instanceRef.current?.destroy?.();
removeWheelEvent();
};
}, [instance]);
return {
initialize: createInstance,
instance: instanceRef,
scrollEventElement: scrollEventElement,
initialized: initialized.current,
getScrollElementScrollableHeight,
getScrollElement,
generateInstance,
destroyInstance: destroyInstance,
updateScrollerPosition: throttledUpdateScrollerPosition,
updateScrollerPositionToTop: updateScrollerPositionToTop,
scrollToBottom: scrollauto,
scrollToTop: updateScrollerPositionToTop,
scrollToTarget
};
}
+29 -5
View File
@@ -52,6 +52,21 @@ import { ExtraContent } from './extraRender';
import { patchRoutes } from './runtime'; import { patchRoutes } from './runtime';
import SiderMenu from './sider-menu'; import SiderMenu from './sider-menu';
// Pages that use the page container in the page
const NO_CONTAINER_PAGES = [
'chat',
'rerank',
'embedding',
'speech',
'image',
'text2images',
'clusterDetail',
'clusterCreate',
'benchmarkDetail',
'deployment',
'video'
];
const CHECK_RESOURCE_PATH = [ const CHECK_RESOURCE_PATH = [
'/resources/workers', '/resources/workers',
'/resources/clusters/list', '/resources/clusters/list',
@@ -254,6 +269,11 @@ export default (props: any) => {
[location.pathname] [location.pathname]
); );
const isNoContainerPage = useMemo(() => {
// @ts-ignore
return NO_CONTAINER_PAGES.includes(matchedRoute?.name as string);
}, [matchedRoute]);
const collapsed = useMemo(() => { const collapsed = useMemo(() => {
return userSettings.collapsed || false; return userSettings.collapsed || false;
}, [userSettings.collapsed]); }, [userSettings.collapsed]);
@@ -449,11 +469,15 @@ export default (props: any) => {
unAccessible={runtimeConfig?.unAccessible} unAccessible={runtimeConfig?.unAccessible}
noAccessible={runtimeConfig?.noAccessible} noAccessible={runtimeConfig?.noAccessible}
> >
<PageContainerInner> {isNoContainerPage ? (
<div> <Outlet />
<Outlet /> ) : (
</div> <PageContainerInner>
</PageContainerInner> <div>
<Outlet />
</div>
</PageContainerInner>
)}
</Exception> </Exception>
</div> </div>
{NoResourceModal} {NoResourceModal}
-4
View File
@@ -267,10 +267,6 @@ export default {
'common.select.count': '{count} selected', 'common.select.count': '{count} selected',
'common.login.auth': 'Authenticating...', 'common.login.auth': 'Authenticating...',
'common.login.auth.failed': 'Authentication failed', 'common.login.auth.failed': 'Authentication failed',
'common.login.error.source_conflict':
'An account with this username already exists from a different authentication source. Please contact an administrator to link or convert it.',
'common.login.error.auth_failed':
'Authentication with the identity provider failed. Please try again or contact your administrator.',
'common.login.password': 'Log in with Password', 'common.login.password': 'Log in with Password',
'common.login.username.holder': 'Please enter username', 'common.login.username.holder': 'Please enter username',
'common.login.password.holder': 'Please enter password', 'common.login.password.holder': 'Please enter password',
-5
View File
@@ -12,11 +12,6 @@ export default {
'users.form.active.description': 'Enable or disable this user account', 'users.form.active.description': 'Enable or disable this user account',
'users.form.fullname': 'Full Name', 'users.form.fullname': 'Full Name',
'users.form.source': 'Source', 'users.form.source': 'Source',
'users.form.source.local': 'Local',
'users.form.source.tip.switchToLocal':
'Switching to Local requires a new password. The user will sign in via the standard login form.',
'users.form.source.tip.switchToExternal':
"Switching to an external source clears the user's local password. They will sign in via the configured identity provider.",
'users.table.user': 'users', 'users.table.user': 'users',
'users.form.admin': 'Admin', 'users.form.admin': 'Admin',
'users.form.user': 'User', 'users.form.user': 'User',
-4
View File
@@ -266,10 +266,6 @@ export default {
'common.select.count': '{count} selected', 'common.select.count': '{count} selected',
'common.login.auth': 'Authenticating...', 'common.login.auth': 'Authenticating...',
'common.login.auth.failed': 'Authentication failed', 'common.login.auth.failed': 'Authentication failed',
'common.login.error.source_conflict':
'このユーザー名のアカウントは別の認証ソースで既に存在します。管理者にリンクまたは変換を依頼してください。',
'common.login.error.auth_failed':
'ID プロバイダーでの認証に失敗しました。再試行するか、管理者にお問い合わせください。',
'common.login.password': 'Log in with Password', 'common.login.password': 'Log in with Password',
'common.login.username.holder': 'Please enter username', 'common.login.username.holder': 'Please enter username',
'common.login.password.holder': 'Please enter password', 'common.login.password.holder': 'Please enter password',
-5
View File
@@ -13,11 +13,6 @@ export default {
'このユーザーアカウントを有効または無効にする', 'このユーザーアカウントを有効または無効にする',
'users.form.fullname': 'フルネーム', 'users.form.fullname': 'フルネーム',
'users.form.source': 'ソース', 'users.form.source': 'ソース',
'users.form.source.local': 'ローカル',
'users.form.source.tip.switchToLocal':
'ローカルに切り替えるには新しいパスワードが必要です。以後、ユーザーは標準のログインフォームからサインインします。',
'users.form.source.tip.switchToExternal':
'外部ソースに切り替えるとユーザーのローカルパスワードが削除され、設定済みの ID プロバイダーからサインインするようになります。',
'users.table.user': 'ユーザー', 'users.table.user': 'ユーザー',
'users.form.admin': '管理者', 'users.form.admin': '管理者',
'users.form.user': '一般ユーザー', 'users.form.user': '一般ユーザー',
-4
View File
@@ -265,10 +265,6 @@ export default {
'common.select.count': '{count} Выбрано', 'common.select.count': '{count} Выбрано',
'common.login.auth': 'Аутентификация...', 'common.login.auth': 'Аутентификация...',
'common.login.auth.failed': 'Ошибка аутентификации', 'common.login.auth.failed': 'Ошибка аутентификации',
'common.login.error.source_conflict':
'Учётная запись с таким именем уже существует, но с другим источником аутентификации. Обратитесь к администратору для связывания или преобразования.',
'common.login.error.auth_failed':
'Не удалось пройти аутентификацию через провайдера идентификации. Попробуйте ещё раз или обратитесь к администратору.',
'common.login.password': 'Войти с паролем', 'common.login.password': 'Войти с паролем',
'common.login.username.holder': 'Введите имя пользователя', 'common.login.username.holder': 'Введите имя пользователя',
'common.login.password.holder': 'Введите пароль', 'common.login.password.holder': 'Введите пароль',
-5
View File
@@ -13,11 +13,6 @@ export default {
'Включить или отключить эту учетную запись пользователя', 'Включить или отключить эту учетную запись пользователя',
'users.form.fullname': 'Полное имя', 'users.form.fullname': 'Полное имя',
'users.form.source': 'Источник', 'users.form.source': 'Источник',
'users.form.source.local': 'Локальный',
'users.form.source.tip.switchToLocal':
'Переключение на «Локальный» требует ввода нового пароля. После этого пользователь будет входить через стандартную форму входа.',
'users.form.source.tip.switchToExternal':
'Переключение на внешний источник удаляет локальный пароль пользователя. После этого вход будет выполняться через настроенного провайдера идентификации.',
'users.table.user': 'пользователи', 'users.table.user': 'пользователи',
'users.form.admin': 'Администратор', 'users.form.admin': 'Администратор',
'users.form.user': 'Пользователь', 'users.form.user': 'Пользователь',
-4
View File
@@ -269,10 +269,6 @@ export default {
'common.select.count': '{count} seçildi', 'common.select.count': '{count} seçildi',
'common.login.auth': 'Kimlik doğrulanıyor...', 'common.login.auth': 'Kimlik doğrulanıyor...',
'common.login.auth.failed': 'Kimlik doğrulama başarısız', 'common.login.auth.failed': 'Kimlik doğrulama başarısız',
'common.login.error.source_conflict':
'Bu kullanıcı adıyla farklı bir kimlik doğrulama kaynağından bir hesap zaten mevcut. Bağlamak veya dönüştürmek için lütfen yöneticinize başvurun.',
'common.login.error.auth_failed':
'Kimlik sağlayıcısı ile kimlik doğrulama başarısız oldu. Lütfen tekrar deneyin veya yöneticinize başvurun.',
'common.login.password': 'Şifre ile giriş yap', 'common.login.password': 'Şifre ile giriş yap',
'common.login.username.holder': 'Lütfen kullanıcı adını girin', 'common.login.username.holder': 'Lütfen kullanıcı adını girin',
'common.login.password.holder': 'Lütfen şifreyi girin', 'common.login.password.holder': 'Lütfen şifreyi girin',
-5
View File
@@ -13,11 +13,6 @@ export default {
'Bu kullanıcı hesabını etkinleştir veya devre dışı bırak', 'Bu kullanıcı hesabını etkinleştir veya devre dışı bırak',
'users.form.fullname': 'Tam Ad', 'users.form.fullname': 'Tam Ad',
'users.form.source': 'Kaynak', 'users.form.source': 'Kaynak',
'users.form.source.local': 'Yerel',
'users.form.source.tip.switchToLocal':
'Yerel kaynağa geçmek yeni bir parola gerektirir. Kullanıcı bundan sonra standart oturum açma formunu kullanır.',
'users.form.source.tip.switchToExternal':
'Harici bir kaynağa geçmek kullanıcının yerel parolasını siler. Kullanıcı bundan sonra yapılandırılmış kimlik sağlayıcı üzerinden oturum açar.',
'users.table.user': 'kullanıcılar', 'users.table.user': 'kullanıcılar',
'users.form.admin': 'Yönetici', 'users.form.admin': 'Yönetici',
'users.form.user': 'Kullanıcı', 'users.form.user': 'Kullanıcı',
-3
View File
@@ -257,9 +257,6 @@ export default {
'common.select.count': '已选 {count} 项', 'common.select.count': '已选 {count} 项',
'common.login.auth': '认证中...', 'common.login.auth': '认证中...',
'common.login.auth.failed': '认证失败', 'common.login.auth.failed': '认证失败',
'common.login.error.source_conflict':
'已存在同名账号但来源不同。请联系管理员关联或转换该账号。',
'common.login.error.auth_failed': '身份提供商认证失败。请重试或联系管理员。',
'common.login.password': '使用密码登录', 'common.login.password': '使用密码登录',
'common.login.username.holder': '请输入用户名', 'common.login.username.holder': '请输入用户名',
'common.login.password.holder': '请输入密码', 'common.login.password.holder': '请输入密码',
-5
View File
@@ -12,11 +12,6 @@ export default {
'users.form.active.description': '启用或禁用此用户账户', 'users.form.active.description': '启用或禁用此用户账户',
'users.form.fullname': '全名', 'users.form.fullname': '全名',
'users.form.source': '来源', 'users.form.source': '来源',
'users.form.source.local': '本地',
'users.form.source.tip.switchToLocal':
'切换到本地需要设置新密码,之后用户将通过标准登录表单登录。',
'users.form.source.tip.switchToExternal':
'切换到外部来源会清除该用户的本地密码,之后用户将通过所配置的身份提供商登录。',
'users.table.user': '用户', 'users.table.user': '用户',
'users.form.admin': '管理员', 'users.form.admin': '管理员',
'users.form.user': '普通用户', 'users.form.user': '普通用户',
+1 -1
View File
@@ -1,5 +1,5 @@
import useCoolColors from '@/hooks/use-cool-colors'; import useCoolColors from '@/hooks/use-cool-colors';
import { Chart } from '@gpustack/core-ui/charts'; import { Chart } from '@gpustack/core-ui';
import { formatLargeNumber } from '@gpustack/core-ui/utils'; import { formatLargeNumber } from '@gpustack/core-ui/utils';
import { Empty, Spin, theme } from 'antd'; import { Empty, Spin, theme } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
+40 -107
View File
@@ -4,53 +4,28 @@ import {
RouteContext, RouteContext,
type PageContainerProps type PageContainerProps
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { import { useOverlayScroller } from '@gpustack/core-ui';
HeaderSlotContext,
useOverlayScroller,
type HeaderSlotContextValue
} from '@gpustack/core-ui';
import { Divider } from 'antd'; import { Divider } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import { import { useContext, useEffect, useRef } from 'react';
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import pageBoxCss from './styles/page-box.less'; import pageBoxCss from './styles/page-box.less';
// The header-slot bridge (HeaderSlotContext, HeaderLeft, HeaderRight,
// usePageContentStyle) lives in @gpustack/core-ui so the host and the
// enterprise plugin share the SAME context instance and can portal into this
// layout-owned header bar. Re-exported here so existing host import sites
// keep working.
export {
HeaderLeft,
HeaderRight,
usePageContentStyle
} from '@gpustack/core-ui';
const paddingInlinePageContainerContent = 24; const paddingInlinePageContainerContent = 24;
export const PageContainerInner: React.FC< export const PageContainerInner: React.FC<
PageContainerProps & { PageContainerProps & {
leftContent?: React.ReactNode;
rightContent?: React.ReactNode;
styles?: { styles?: {
containerWrapper?: React.CSSProperties; containerWrapper?: React.CSSProperties;
}; };
} }
> = ({ children, styles, title, ...rest }) => { > = ({ children, styles, title, leftContent, rightContent, ...rest }) => {
const { initialize: initialize } = useOverlayScroller({ const { initialize: initialize } = useOverlayScroller({
defer: false defer: false
}); });
const pageContext = useContext(RouteContext); const pageContext = useContext(RouteContext);
const contentWrapperRef = useRef<HTMLDivElement>(null); const contentWrapperRef = useRef<HTMLDivElement>(null);
const [leftEl, setLeftEl] = useState<HTMLDivElement | null>(null);
const [rightEl, setRightEl] = useState<HTMLDivElement | null>(null);
const [contentStyleOverride, setContentStyleOverride] = useState<
React.CSSProperties | undefined
>(undefined);
useEffect(() => { useEffect(() => {
if (contentWrapperRef.current) { if (contentWrapperRef.current) {
@@ -59,85 +34,43 @@ export const PageContainerInner: React.FC<
} }
}, [initialize, contentWrapperRef]); }, [initialize, contentWrapperRef]);
const setContentStyle = useCallback(
(style: React.CSSProperties | undefined) => {
setContentStyleOverride(style);
return () => setContentStyleOverride(undefined);
},
[]
);
const slotOwnersRef = useRef<{ left: number; right: number }>({
left: 0,
right: 0
});
const registerSlot = useCallback((slot: 'left' | 'right') => {
const owners = slotOwnersRef.current;
owners[slot] += 1;
if (process.env.NODE_ENV !== 'production' && owners[slot] > 1) {
const name = slot === 'left' ? 'HeaderLeft' : 'HeaderRight';
console.warn(
`[PageContainerInner] ${owners[slot]} <${name}> are mounted at once; their content stacks in the same header slot. Only one page/component should own each slot at a time.`
);
}
return () => {
owners[slot] -= 1;
};
}, []);
const slotValue = useMemo<HeaderSlotContextValue>(
() => ({
leftEl,
rightEl,
setContentStyle,
registerSlot
}),
[leftEl, rightEl, setContentStyle, registerSlot]
);
return ( return (
<HeaderSlotContext.Provider value={slotValue}> <div className={pageBoxCss.containerWrapper}>
<div className={pageBoxCss.containerWrapper}> <PageContainer
<PageContainer {...rest}
{...rest} fixedHeader={false}
fixedHeader={false} title={false}
title={false} pageHeaderRender={false}
pageHeaderRender={false} style={{
style={{ flex: 1
flex: 1 }}
}} token={{
token={{ paddingInlinePageContainerContent: paddingInlinePageContainerContent
paddingInlinePageContainerContent: paddingInlinePageContainerContent }}
}} >
<div className={pageBoxCss.title}>
<div className={pageBoxCss.left}>
{leftContent || pageContext.title}
</div>
<div className={pageBoxCss.right}>
{rightContent && (
<div>
{rightContent}
<Divider orientation="vertical" style={{ margin: '0 16px' }} />
</div>
)}
<ExtraContent />
</div>
</div>
<div
className={classNames(pageBoxCss.contentWrapper)}
style={styles?.containerWrapper}
ref={contentWrapperRef}
> >
<div className={pageBoxCss.title}> {children}
<div className={pageBoxCss.left}> </div>
<div ref={setLeftEl} className={pageBoxCss.leftSlot} /> </PageContainer>
<span className={pageBoxCss.defaultTitle}> </div>
{pageContext.title}
</span>
</div>
<div className={pageBoxCss.right}>
<div ref={setRightEl} className={pageBoxCss.rightSlot} />
<Divider
className={pageBoxCss.divider}
orientation="vertical"
style={{ margin: '0 16px' }}
/>
<ExtraContent />
</div>
</div>
<div
className={classNames(pageBoxCss.contentWrapper)}
style={{ ...styles?.containerWrapper, ...contentStyleOverride }}
ref={contentWrapperRef}
>
{children}
</div>
</PageContainer>
</div>
</HeaderSlotContext.Provider>
); );
}; };
+1 -1
View File
@@ -1,5 +1,5 @@
import useCoolColors from '@/hooks/use-cool-colors'; import useCoolColors from '@/hooks/use-cool-colors';
import { Chart } from '@gpustack/core-ui/charts'; import { Chart } from '@gpustack/core-ui';
import { formatLargeNumber } from '@gpustack/core-ui/utils'; import { formatLargeNumber } from '@gpustack/core-ui/utils';
import { Empty, Spin, theme } from 'antd'; import { Empty, Spin, theme } from 'antd';
import React, { import React, {
@@ -70,44 +70,14 @@
height: 56px; height: 56px;
border-bottom: 1px solid var(--ant-color-split); border-bottom: 1px solid var(--ant-color-split);
// `.left` claims all the space between the title's left edge and `.right`.
// Because the container width is fixed regardless of inner content, the
// right side never shifts during route transitions and the content inside
// .left is anchored to the left edge — no horizontal jitter.
.left { .left {
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1 1 auto;
min-width: 0;
} }
.right { .right {
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0;
}
.leftSlot,
.rightSlot {
display: contents;
}
.defaultTitle {
display: inline-flex;
align-items: center;
}
// Portal-driven visibility: when a page's HeaderLeft / HeaderRight
// portal target has children, hide the default title / show the divider.
// Using `:empty` (rather than React state) keeps visibility synchronous
// with the DOM mutation, so there is no inter-frame flicker on route
// transitions.
.leftSlot:not(:empty) ~ .defaultTitle {
display: none;
}
.rightSlot:empty ~ .divider {
display: none;
} }
} }
} }
+24
View File
@@ -0,0 +1,24 @@
import { PageContainer } from '@ant-design/pro-components';
import { Access, useAccess } from '@umijs/max';
import { Button } from 'antd';
const AccessPage: React.FC = () => {
const access = useAccess();
return (
<PageContainer
ghost
header={{
title: 'permission example',
style: {
paddingInline: 'var(--layout-content-header-inlinepadding)'
}
}}
>
<Access accessible={access.canSeeAdmin}>
<Button>Only Admin can see this button</Button>
</Access>
</PageContainer>
);
};
export default AccessPage;
@@ -1,6 +1,6 @@
import FullMarkdown from '@/components/full-markdown';
import { BulbOutlined } from '@ant-design/icons'; import { BulbOutlined } from '@ant-design/icons';
import { AutoTooltip } from '@gpustack/core-ui'; import { AutoTooltip } from '@gpustack/core-ui';
import { FullMarkdown } from '@gpustack/core-ui/markdown';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button, Tag } from 'antd'; import { Button, Tag } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
@@ -1,5 +1,5 @@
import { IconFont } from '@gpustack/core-ui'; import { IconFont } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl, useSearchParams } from '@umijs/max';
import { Tabs, TabsProps } from 'antd'; import { Tabs, TabsProps } from 'antd';
import React, { useState } from 'react'; import React, { useState } from 'react';
import Environment from './environment'; import Environment from './environment';
@@ -12,6 +12,7 @@ const Details: React.FC<{
}> = ({ tabBarExtraContent }) => { }> = ({ tabBarExtraContent }) => {
const intl = useIntl(); const intl = useIntl();
const [activeKey, setActiveKey] = useState('summary'); const [activeKey, setActiveKey] = useState('summary');
const [searchParams] = useSearchParams();
const items: TabsProps['items'] = [ const items: TabsProps['items'] = [
{ {
+5 -6
View File
@@ -2,7 +2,7 @@ import { BaseSelect, DeleteModal } from '@gpustack/core-ui';
import { useIntl, useNavigate, useSearchParams } from '@umijs/max'; import { useIntl, useNavigate, useSearchParams } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { HeaderLeft } from '../_components/page-box'; import { PageContainerInner } from '../_components/page-box';
import PageBreadcrumb from '../_components/page-breadcrumb'; import PageBreadcrumb from '../_components/page-breadcrumb';
import { deleteBenchmark } from './apis'; import { deleteBenchmark } from './apis';
import DetailContent from './components/detail-content'; import DetailContent from './components/detail-content';
@@ -122,10 +122,9 @@ const Details: React.FC = () => {
}, [id]); }, [id]);
return ( return (
<> <PageContainerInner
<HeaderLeft> leftContent={<PageBreadcrumb items={breadcrumbItems} />}
<PageBreadcrumb items={breadcrumbItems} /> >
</HeaderLeft>
<DetailContext.Provider <DetailContext.Provider
value={{ value={{
detailData: detailData || {}, detailData: detailData || {},
@@ -165,7 +164,7 @@ const Details: React.FC = () => {
onCancel={closeViewLogsModal} onCancel={closeViewLogsModal}
></ViewLogsModal> ></ViewLogsModal>
<DeleteModal ref={modalRef}></DeleteModal> <DeleteModal ref={modalRef}></DeleteModal>
</> </PageContainerInner>
); );
}; };
+14 -47
View File
@@ -1,15 +1,15 @@
import { clusterDetailAtom } from '@/atoms/clusters';
import GPUList from '@/pages/resources/components/gpus'; import GPUList from '@/pages/resources/components/gpus';
import WorkerList from '@/pages/resources/components/workers'; import WorkerList from '@/pages/resources/components/workers';
import { getGPUStackPlugin } from '@/plugins'; import { getGPUStackPlugin } from '@/plugins';
import { BaseSelect, IconFont } from '@gpustack/core-ui'; import { IconFont } from '@gpustack/core-ui';
import { useIntl, useNavigate, useSearchParams } from '@umijs/max'; import { useIntl, useNavigate, useSearchParams } from '@umijs/max';
import { Tabs, type TabsProps } from 'antd'; import { Tabs, type TabsProps } from 'antd';
import { useEffect } from 'react'; import { useAtomValue } from 'jotai';
import { HeaderLeft } from '../_components/page-box'; import { PageContainerInner } from '../_components/page-box';
import PageBreadcrumb from '../_components/page-breadcrumb'; import PageBreadcrumb from '../_components/page-breadcrumb';
import ClusterBasic from './components/detail/cluster-basic'; import ClusterBasic from './components/detail/cluster-basic';
import ClusterSystemLoad from './components/detail/cluster-system-load'; import ClusterSystemLoad from './components/detail/cluster-system-load';
import { useQueryClusterList } from './services/use-query-cluster-list';
const ClusterDetailModal = () => { const ClusterDetailModal = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,18 +17,7 @@ const ClusterDetailModal = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const id = searchParams.get('id'); const id = searchParams.get('id');
const clusterName = searchParams.get('name'); const clusterName = searchParams.get('name');
const { clusterList, fetchClusterList, cancelRequest } = const clusterDetailData = useAtomValue(clusterDetailAtom);
useQueryClusterList();
// Switch to another cluster's detail from the breadcrumb. Navigating with a
// new ``id`` re-renders this page in place; the detail children refetch on
// their ``clusterId`` prop change, so the whole view updates.
const handleOnChange = (value: number, option: any) => {
navigate(
`/resources/clusters/detail?id=${option.value}&name=${option.label}&page=clusters`,
{ replace: true }
);
};
const breadcrumbItems = [ const breadcrumbItems = [
{ {
@@ -36,27 +25,10 @@ const ClusterDetailModal = () => {
onClick: () => navigate(-1) onClick: () => navigate(-1)
}, },
{ {
title: ( title: clusterName
<BaseSelect
size="small"
variant="borderless"
options={clusterList}
value={clusterName}
style={{ minWidth: 100 }}
popupMatchSelectWidth={false}
onChange={handleOnChange}
></BaseSelect>
)
} }
]; ];
useEffect(() => {
fetchClusterList({ page: -1 });
return () => {
cancelRequest();
};
}, []);
// Extension slot: a registered plugin may inject additional tab // Extension slot: a registered plugin may inject additional tab
// items (e.g. per-cluster access / quota panels) by exporting // items (e.g. per-cluster access / quota panels) by exporting
// ``clusterDetail.extraTabs(clusterId, intl)`` that returns an // ``clusterDetail.extraTabs(clusterId, intl)`` that returns an
@@ -74,10 +46,11 @@ const ClusterDetailModal = () => {
) ?? []) as TabItem[]; ) ?? []) as TabItem[];
return ( return (
<> <PageContainerInner
<HeaderLeft> header={{
<PageBreadcrumb items={breadcrumbItems} /> title: <PageBreadcrumb items={breadcrumbItems} />
</HeaderLeft> }}
>
<ClusterBasic clusterId={Number(id)}></ClusterBasic> <ClusterBasic clusterId={Number(id)}></ClusterBasic>
<ClusterSystemLoad clusterId={Number(id)}></ClusterSystemLoad> <ClusterSystemLoad clusterId={Number(id)}></ClusterSystemLoad>
<Tabs <Tabs
@@ -90,25 +63,19 @@ const ClusterDetailModal = () => {
label: intl.formatMessage({ id: 'resources.nodes' }), label: intl.formatMessage({ id: 'resources.nodes' }),
icon: <IconFont type="icon-resources" />, icon: <IconFont type="icon-resources" />,
children: ( children: (
<WorkerList <WorkerList clusterId={Number(id)} source="clusterDetail" />
key={id}
clusterId={Number(id)}
source="clusterDetail"
/>
) )
}, },
{ {
key: 'gpus', key: 'gpus',
label: intl.formatMessage({ id: 'menu.resources.gpus' }), label: intl.formatMessage({ id: 'menu.resources.gpus' }),
icon: <IconFont type="icon-gpu1" />, icon: <IconFont type="icon-gpu1" />,
children: ( children: <GPUList clusterId={Number(id)} source="clusterDetail" />
<GPUList key={id} clusterId={Number(id)} source="clusterDetail" />
)
}, },
...extraTabs ...extraTabs
]} ]}
/> />
</> </PageContainerInner>
); );
}; };
@@ -1,5 +1,4 @@
import { TemplateCard } from '@gpustack/core-ui'; import { GaugeChart, TemplateCard } from '@gpustack/core-ui';
import { GaugeChart } from '@gpustack/core-ui/charts';
import { useSearchParams } from '@umijs/max'; import { useSearchParams } from '@umijs/max';
import { Col, Row } from 'antd'; import { Col, Row } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
@@ -1,4 +1,4 @@
import { LineChart } from '@gpustack/core-ui/charts'; import { LineChart } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import _ from 'lodash'; import _ from 'lodash';
@@ -1,4 +1,4 @@
import { LineChart } from '@gpustack/core-ui/charts'; import { LineChart } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import _ from 'lodash'; import _ from 'lodash';
@@ -1,6 +1,10 @@
import { queryClusterList } from '@/pages/cluster-management/apis'; import { queryClusterList } from '@/pages/cluster-management/apis';
import { BaseSelect, CardWrapper, PageTools } from '@gpustack/core-ui'; import {
import { GaugeChart } from '@gpustack/core-ui/charts'; BaseSelect,
CardWrapper,
GaugeChart,
PageTools
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Col, Row } from 'antd'; import { Col, Row } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
@@ -1,4 +1,4 @@
import { HBarChart } from '@gpustack/core-ui/charts'; import { HBarChart } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { usageChartHeight, usageRankMaxItems } from '../../config'; import { usageChartHeight, usageRankMaxItems } from '../../config';
import type useTopTokenUsageByUser from '../../hooks/use-top-token-usage-by-user'; import type useTopTokenUsageByUser from '../../hooks/use-top-token-usage-by-user';
@@ -1,6 +1,6 @@
import useCoolColors from '@/hooks/use-cool-colors'; import useCoolColors from '@/hooks/use-cool-colors';
import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data'; import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data';
import { Chart } from '@gpustack/core-ui/charts'; import { Chart } from '@gpustack/core-ui';
import { formatLargeNumber } from '@gpustack/core-ui/utils'; import { formatLargeNumber } from '@gpustack/core-ui/utils';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import type { GlobalToken } from 'antd'; import type { GlobalToken } from 'antd';
@@ -57,7 +57,7 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
.fetchData({ .fetchData({
...commonParams, ...commonParams,
metric: 'total_tokens', metric: 'total_tokens',
group_by: ['route'], group_by: ['date', 'route'],
page: 1, page: 1,
perPage: 10, perPage: 10,
sort_by: '-total_tokens' sort_by: '-total_tokens'
@@ -67,7 +67,7 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
.fetchData({ .fetchData({
...commonParams, ...commonParams,
metric: 'api_requests', metric: 'api_requests',
group_by: ['route'], group_by: ['date', 'route'],
page: 1, page: 1,
perPage: 10, perPage: 10,
sort_by: '-api_requests' sort_by: '-api_requests'
@@ -1,6 +1,5 @@
import { formatLargeNumber } from '@/utils'; import { formatLargeNumber } from '@/utils';
import { CardWrapper, SimpleCard } from '@gpustack/core-ui'; import { CardWrapper, MixLineBarChart, SimpleCard } from '@gpustack/core-ui';
import { MixLineBarChart } from '@gpustack/core-ui/charts';
import { Button } from 'antd'; import { Button } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
@@ -1,5 +1,4 @@
import { CardWrapper } from '@gpustack/core-ui'; import { CardWrapper, HBarChart } from '@gpustack/core-ui';
import { HBarChart } from '@gpustack/core-ui/charts';
import React from 'react'; import React from 'react';
interface TopUserProps { interface TopUserProps {
@@ -77,7 +77,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
}, [resourceAtom, loadingStatus]); }, [resourceAtom, loadingStatus]);
const contentInfo = useMemo(() => { const contentInfo = useMemo(() => {
if (!resourceCount?.cluster_count) { if (!resourceCount.cluster_count) {
return { return {
title: intl.formatMessage({ id: 'noresult.cluster.title' }), title: intl.formatMessage({ id: 'noresult.cluster.title' }),
subTitle: intl.formatMessage({ id: 'noresult.resources.cluster' }), subTitle: intl.formatMessage({ id: 'noresult.resources.cluster' }),
@@ -98,7 +98,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
const handleCreate = () => { const handleCreate = () => {
setHideModalTemporarily(true); setHideModalTemporarily(true);
onCreated?.(); onCreated?.();
if (!resourceCount?.cluster_count) { if (!resourceCount.cluster_count) {
setClusterSession({ setClusterSession({
firstAddWorker: false, firstAddWorker: false,
firstAddCluster: true firstAddCluster: true
@@ -18,7 +18,7 @@ export default function useTopTokenUsageByUser(
.fetchData({ .fetchData({
...commonParams, ...commonParams,
metric: 'total_tokens', metric: 'total_tokens',
group_by: ['user'], group_by: ['date', 'user'],
page: 1, page: 1,
perPage: 10, perPage: 10,
sort_by: '-total_tokens' sort_by: '-total_tokens'
@@ -7,7 +7,6 @@ import metaxLogo from '@/assets/logo/metax.png';
import mooreLogo from '@/assets/logo/moore-logo.png'; import mooreLogo from '@/assets/logo/moore-logo.png';
import nvidiaLogo from '@/assets/logo/nvidia.png'; import nvidiaLogo from '@/assets/logo/nvidia.png';
import pytorchBlackLogo from '@/assets/logo/pytorch_black.png'; import pytorchBlackLogo from '@/assets/logo/pytorch_black.png';
import pytorchLightLogo from '@/assets/logo/pytorch_light.png';
import sgLangLogo from '@/assets/logo/sglang.png'; import sgLangLogo from '@/assets/logo/sglang.png';
import theadLogoEN from '@/assets/logo/t-head-en.png'; import theadLogoEN from '@/assets/logo/t-head-en.png';
import theadLogoZH from '@/assets/logo/t-head-zh.png'; import theadLogoZH from '@/assets/logo/t-head-zh.png';
@@ -15,7 +14,6 @@ import tensorflowkLogo from '@/assets/logo/tensorflow.svg';
import ubuntuLogo from '@/assets/logo/ubuntu_logo.png'; import ubuntuLogo from '@/assets/logo/ubuntu_logo.png';
import vllmLogo from '@/assets/logo/vllm.png'; import vllmLogo from '@/assets/logo/vllm.png';
import PluginExtraFields from '@/components/plugin-extra-fields'; import PluginExtraFields from '@/components/plugin-extra-fields';
import useUserSettings from '@/hooks/use-user-settings';
import OwnerTag from '@/pages/gpu-service/components/owner-tag'; import OwnerTag from '@/pages/gpu-service/components/owner-tag';
import { import {
GPUsConfigs, GPUsConfigs,
@@ -35,8 +33,7 @@ import styled from 'styled-components';
import { manufactureColorMap, templateActions } from '../config'; import { manufactureColorMap, templateActions } from '../config';
import { ListItem } from '../config/types'; import { ListItem } from '../config/types';
// Light theme logos const imageLogoMap = {
const imageLogoLightMap = {
vllm: vllmLogo, vllm: vllmLogo,
sglang: sgLangLogo, sglang: sgLangLogo,
jupyter: jupyterLogo, jupyter: jupyterLogo,
@@ -45,31 +42,25 @@ const imageLogoLightMap = {
ubuntu: ubuntuLogo ubuntu: ubuntuLogo
} as const; } as const;
// Dark theme logos: inherit light, override only the ones that need a variant
const imageLogoDarkMap: typeof imageLogoLightMap = {
...imageLogoLightMap,
pytorch: pytorchLightLogo
};
const matchImageLogo = ( const matchImageLogo = (
image: string | undefined, image: string | undefined
isDark: boolean
): { logo: string; type: string } | null => { ): { logo: string; type: string } | null => {
if (!image) return null; if (!image) return null;
const logoMap = imageLogoLightMap;
const lower = image.toLowerCase(); const lower = image.toLowerCase();
let matched: keyof typeof logoMap | null = null; let matched: keyof typeof imageLogoMap | null = null;
let earliest = Infinity; let earliest = Infinity;
(Object.keys(logoMap) as Array<keyof typeof logoMap>).forEach((key) => { (Object.keys(imageLogoMap) as Array<keyof typeof imageLogoMap>).forEach(
const idx = lower.indexOf(key); (key) => {
if (idx !== -1 && idx < earliest) { const idx = lower.indexOf(key);
earliest = idx; if (idx !== -1 && idx < earliest) {
matched = key; earliest = idx;
matched = key;
}
} }
}); );
return matched return matched
? { ? {
logo: logoMap[matched], logo: imageLogoMap[matched],
type: matched type: matched
} }
: null; : null;
@@ -144,7 +135,6 @@ interface TemplateCardProps {
const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => { const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
const intl = useIntl(); const intl = useIntl();
const { isDarkTheme } = useUserSettings();
const manufacturerLabelMap: Record<string, string> = useMemo(() => { const manufacturerLabelMap: Record<string, string> = useMemo(() => {
return Object.values(GPUsConfigs).reduce( return Object.values(GPUsConfigs).reduce(
@@ -168,7 +158,7 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
}; };
const renderLogo = () => { const renderLogo = () => {
const imageLogo = matchImageLogo(data.spec?.image, isDarkTheme); const imageLogo = matchImageLogo(data.spec?.image);
if (imageLogo?.logo) { if (imageLogo?.logo) {
return ( return (
<LogoImg <LogoImg
+29 -26
View File
@@ -1,7 +1,6 @@
import { convertFileSize } from '@/utils'; import { convertFileSize } from '@/utils';
import { AutoTooltip } from '@gpustack/core-ui'; import { AutoTooltip } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Flex } from 'antd';
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import '../style/gpu-card.less'; import '../style/gpu-card.less';
@@ -20,17 +19,12 @@ const Header = styled.div`
width: 100%; width: 100%;
`; `;
const Metric: React.FC<{ label: React.ReactNode; value: React.ReactNode }> = ({ const ItemInfo = styled.div`
label, display: flex;
value flex-wrap: wrap;
}) => ( white-space: break-spaces;
<span style={{ lineHeight: 1.2 }}> font-size: 13px;
{label}:{' '} `;
<span className="font-500" style={{ color: 'var(--ant-color-text)' }}>
{value}
</span>
</span>
);
const Description = styled.div` const Description = styled.div`
display: flex; display: flex;
@@ -70,20 +64,29 @@ const GPUCard: React.FC<{
} }
description={ description={
info || ( info || (
<Flex wrap gap={8} style={{ fontSize: 13 }}> <>
<Metric <ItemInfo>
label={intl.formatMessage({ id: 'resources.table.total' })} {intl.formatMessage({ id: 'resources.table.vram' })}(
value={convertFileSize(data?.memory?.total || 0)} {intl.formatMessage({ id: 'resources.table.used' })}/
/> {intl.formatMessage({ id: 'resources.table.total' })}):{' '}
<Metric <span>
label={intl.formatMessage({ id: 'resources.table.used' })} {convertFileSize(
value={convertFileSize(data?.memory?.used || 0)} data?.memory?.used || data?.memory?.allocated || 0
/> )}{' '}
<Metric / {convertFileSize(data?.memory?.total || 0)}
label={intl.formatMessage({ id: 'resources.table.allocated' })} </span>
value={convertFileSize(data?.memory?.allocated || 0)} </ItemInfo>
/> {/* <ItemInfo>
</Flex> <span>
{intl.formatMessage({ id: 'resources.table.gpuutilization' })}
:{' '}
</span>
{data?.memory?.used
? _.round(data?.memory?.utilization_rate || 0, 2)
: _.round(data.memory?.allocated / data.memory?.total, 2) * 100}
%
</ItemInfo> */}
</>
) )
} }
></CardContainer> ></CardContainer>
+5 -10
View File
@@ -1,4 +1,5 @@
import { import {
IconFont,
StatusTag, StatusTag,
TagsWrapper, TagsWrapper,
TemplateCard, TemplateCard,
@@ -19,7 +20,6 @@ import {
MyModelsStatusValueMap MyModelsStatusValueMap
} from '../config'; } from '../config';
import { categoryToPathMap } from '../config/button-actions'; import { categoryToPathMap } from '../config/button-actions';
import { getModelLogo } from '../utils/model-logo';
const CardWrapper = styled.div` const CardWrapper = styled.div`
&:hover { &:hover {
@@ -84,14 +84,6 @@ const ModelItemContent = styled.div`
} }
`; `;
const ModelLogo = styled.img`
width: 24px;
height: 24px;
border-radius: 4px;
object-fit: contain;
flex: none;
`;
const Header = styled.div` const Header = styled.div`
margin-bottom: 8px; margin-bottom: 8px;
display: flex; display: flex;
@@ -184,7 +176,10 @@ const ModelItem: React.FC<{
header={ header={
<Header> <Header>
<span className="text gap-8"> <span className="text gap-8">
<ModelLogo src={getModelLogo(model.name)} alt="" /> <IconFont
type={sourceIconMap[model.source]}
style={{ fontSize: 24 }}
/>
<span>{model.name}</span> <span>{model.name}</span>
</span> </span>
<StatusTag <StatusTag
+23 -22
View File
@@ -13,7 +13,7 @@ import {
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form } from 'antd'; import { Form } from 'antd';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { DeployFormKeyMap, sourceOptions } from '../config'; import { DeployFormKeyMap, sourceOptions } from '../config';
import { useFormContext } from '../config/form-context'; import { useFormContext } from '../config/form-context';
@@ -24,7 +24,6 @@ import CustomBackend from './custom-backend';
import LocalPathSource from './local-path-source'; import LocalPathSource from './local-path-source';
import ModeField from './mode-field'; import ModeField from './mode-field';
import OnlineSource from './online-source'; import OnlineSource from './online-source';
const ClusterOption = styled.span` const ClusterOption = styled.span`
display: flex; display: flex;
padding: 8px 0; padding: 8px 0;
@@ -117,6 +116,7 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
// cluster dropdown to that org's own clusters — the backend derives the // cluster dropdown to that org's own clusters — the backend derives the
// deployment's owner from the chosen cluster, so this keeps them aligned. // deployment's owner from the chosen cluster, so this keeps them aligned.
const scopeOrgId = Form.useWatch('organization_id', form); const scopeOrgId = Form.useWatch('organization_id', form);
const prevScopeRef = useRef<number | null | undefined>(undefined);
const clusterOptions = useMemo(() => { const clusterOptions = useMemo(() => {
return clusterList return clusterList
@@ -139,36 +139,37 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
}); });
}, [clusterList, scopeOrgId]); }, [clusterList, scopeOrgId]);
// Keep the cluster selection consistent with the available (scoped) // On a genuine org change (not the initial value — the modal's open
// options. The modal's open handler seeds a cluster, but that seed can be // handler seeds the first cluster), drop a now-out-of-scope cluster and
// empty (options not loaded yet) or point outside the current scope (the // re-pick within the new org so GPU/backend options refetch for it.
// scope id and the option list both settle after mount, and an org switch
// re-scopes the list) — leaving the field blank even though a valid option
// exists. Whenever the scope or the options change, drop an invalid/empty
// selection and fall back to the scope's default cluster (then a Ready one,
// then the first) so GPU/backend options refetch for it. A selection that's
// still valid is left untouched, so a user's (or edit's) choice is kept.
useEffect(() => { useEffect(() => {
if (!clusterOptions?.length) { if (prevScopeRef.current === undefined) {
prevScopeRef.current = scopeOrgId;
return; return;
} }
if (prevScopeRef.current === scopeOrgId) {
return;
}
prevScopeRef.current = scopeOrgId;
const current = form.getFieldValue('cluster_id'); const current = form.getFieldValue('cluster_id');
const stillValid = clusterOptions.some((c) => c.value === current); const stillValid = clusterOptions?.some((c) => c.value === current);
if (current != null && stillValid) { if (stillValid) {
return; return;
} }
const next = const next =
clusterOptions.find((c) => c.is_default)?.value ?? clusterOptions?.find((c) => c.is_default)?.value ??
clusterOptions.find((c) => c.state === ClusterStatusValueMap.Ready) clusterOptions?.find((c) => c.state === ClusterStatusValueMap.Ready)
?.value ?? ?.value ??
clusterOptions[0]?.value ?? clusterOptions?.[0]?.value ??
null; null;
if (next == null || next === current) { form.setFieldValue('cluster_id', next ?? null);
return; if (next != null) {
handleClusterChange?.(next as number);
} }
form.setFieldValue('cluster_id', next); // The prevScopeRef guard above makes this a no-op unless scopeOrgId
handleClusterChange?.(next); // actually changed, so listing the other deps is safe (no extra runs).
}, [clusterOptions, form, handleClusterChange]); }, [scopeOrgId, clusterOptions, form, handleClusterChange]);
const clusterOptionRender = (option: any) => { const clusterOptionRender = (option: any) => {
const { data } = option; const { data } = option;
@@ -20,7 +20,6 @@ import {
import { useFormContext } from '../config/form-context'; import { useFormContext } from '../config/form-context';
import { FormData } from '../config/types'; import { FormData } from '../config/types';
import { backendOptionsMap } from '../constants/backend-parameters'; import { backendOptionsMap } from '../constants/backend-parameters';
import '../style/gpu-selector.less';
const InputWrapper = styled.div` const InputWrapper = styled.div`
padding: 8px 4px; padding: 8px 4px;
+10 -9
View File
@@ -1,7 +1,4 @@
import { import { PageContainerInner } from '@/pages/_components/page-box';
HeaderLeft,
usePageContentStyle
} from '@/pages/_components/page-box';
import { IconFont } from '@gpustack/core-ui'; import { IconFont } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
@@ -108,11 +105,15 @@ const LLModels: React.FC = () => {
}; };
}, [activeKey]); }, [activeKey]);
usePageContentStyle({ padding: 0 });
return ( return (
<> <PageContainerInner
<HeaderLeft>{title}</HeaderLeft> leftContent={title}
styles={{
containerWrapper: {
padding: 0
}
}}
>
<DeploymentsContext.Provider <DeploymentsContext.Provider
value={{ value={{
generateFormValues, generateFormValues,
@@ -137,7 +138,7 @@ const LLModels: React.FC = () => {
activeKey={activeKey} activeKey={activeKey}
></Tabs> ></Tabs>
</DeploymentsContext.Provider> </DeploymentsContext.Provider>
</> </PageContainerInner>
); );
}; };
@@ -1,5 +0,0 @@
.cascader-popup-wrapper.gpu-selector {
.ant-cascader-menu-item-content {
padding-right: 0;
}
}
-94
View File
@@ -1,94 +0,0 @@
// Load every image under src/assets/model_icons at build time.
const iconContext = (require as any).context(
'@/assets/model_icons',
false,
/\.(png|jpe?g|svg)$/
);
const DEFAULT_ICON = 'llm-icon';
// Map of lowercased base filename (without extension) -> resolved asset url.
const iconMap: Record<string, string> = iconContext
.keys()
.reduce((acc: Record<string, string>, key: string) => {
const base = key
.replace(/^\.\//, '')
.replace(/\.(png|jpe?g|svg)$/, '')
.toLowerCase();
acc[base] = iconContext(key).default || iconContext(key);
return acc;
}, {});
// 模型名关键字 → logo 文件名(按顺序匹配第一个命中的子串)。
const MODEL_ICON_RULES: [string, string][] = [
['llama', 'meta'],
['qwen', 'qwen'],
['deepseek', 'deepseek'],
['mixtral', 'mistral'],
['mistral', 'mistral'],
['phi', 'microsoft'],
['gemma', 'google'],
['command-r', 'cohere'],
['cohere', 'cohere'],
['glm', 'thudm'],
['yi-', '01ai'],
['ernie', 'ernie'],
['hunyuan', 'hunyuan'],
['kimi', 'kimi'],
['minimax', 'minimax'],
['falcon', 'falcon3'],
['stable-diffusion', 'stability'],
['sdxl', 'stability'],
['stepfun', 'stepfun'],
['nvidia', 'nvidia'],
// 其余厂商/系列 → 对应 logo(按子串命中)
['gemini', 'google'],
['gpt', 'openai'],
['openai', 'openai'],
['internvl', 'opengvlab'],
['minicpm', 'openbmb'],
['mimo', 'xiaomimimo'],
['flux', 'blackforestlabs'],
['granite', 'ibm'],
['lfm', 'liquidai'],
['liquid', 'liquidai'],
['nomic', 'nomic'],
['jina', 'jina'],
['bge', 'bge'],
['cosyvoice', 'funaudiollm'],
['sensevoice', 'funaudiollm'],
['paddle', 'paddlepaddle'],
['baidu', 'baidu'],
['youdao', 'youdao'],
['systran', 'systran'],
['nanbeige', 'nanbeige'],
['nari', 'narilabs'],
['bagel', 'bagel'],
['zai', 'zai'],
['tencent', 'tencentarc'],
['jetbrains', 'jetbrains'],
['freepik', 'freepik'],
['alibaba', 'alibaba']
];
/**
* Resolve a model logo from its name.
* 1. Match against the keyword rules in order (first substring hit wins).
* 2. Fall back to a direct match where the name contains an icon filename.
* 3. Fall back to the default llm icon.
*/
export const getModelLogo = (modelName?: string): string => {
const name = (modelName || '').toLowerCase();
const rule = MODEL_ICON_RULES.find(([keyword]) => name.includes(keyword));
if (rule && iconMap[rule[1]]) {
return iconMap[rule[1]];
}
const directHit = Object.keys(iconMap)
.filter((iconName) => iconName !== DEFAULT_ICON)
.sort((a, b) => b.length - a.length)
.find((iconName) => name.includes(iconName));
return iconMap[directHit || DEFAULT_ICON] || iconMap[DEFAULT_ICON];
};

Some files were not shown because too many files have changed in this diff Show More