Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
207cef350f | ||
|
|
fa3fed8671 | ||
|
|
dc41b61d69 | ||
|
|
3a1565690d | ||
|
|
1bb9fd73db | ||
|
|
39024b1d46 | ||
|
|
4ecaa0a326 | ||
|
|
e1af27e11c | ||
|
|
f4ebd8d7d1 | ||
|
|
bd58a911d5 | ||
|
|
5321973ebe | ||
|
|
0a1f875590 | ||
|
|
ea2c9870a7 | ||
|
|
23f0e4398a | ||
|
|
b342174fc2 | ||
|
|
d07d3b2c99 | ||
|
|
9d0a5d01b0 | ||
|
|
1e97d29fca | ||
|
|
350f398cde | ||
|
|
8a15172316 | ||
|
|
185e3475e7 | ||
|
|
64720ccbfa | ||
|
|
db17192e55 | ||
|
|
a1dd1ec861 |
@@ -1,6 +1,7 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch: {}
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
@@ -117,3 +118,20 @@ 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 }}"
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ 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:
|
||||||
@@ -101,6 +111,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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.32",
|
"@gpustack/core-ui": "^1.0.35",
|
||||||
"@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,7 +39,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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.32
|
specifier: ^1.0.35
|
||||||
version: 1.0.32(czdvzceysqw7iv6pct2ucnb23e)
|
version: 1.0.35(czdvzceysqw7iv6pct2ucnb23e)
|
||||||
'@huggingface/gguf':
|
'@huggingface/gguf':
|
||||||
specifier: ^0.1.7
|
specifier: ^0.1.7
|
||||||
version: 0.1.18
|
version: 0.1.18
|
||||||
@@ -89,9 +89,6 @@ 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
|
||||||
@@ -1484,8 +1481,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.32':
|
'@gpustack/core-ui@1.0.35':
|
||||||
resolution: {integrity: sha512-kGTazoqbK2KyZgOP6gmQaRxTiQVfF2IKLGDXjJq6w6BbmJgALXFJA2v2ROjAbjEVyfTdBzyYeXfPo/JgISpMNw==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.32.tgz}
|
resolution: {integrity: sha512-MaEmCM3FikeKZdUgDSyUtIfXtNMbNOLeFiTynl6DcKEraUOpk6tgQXPDginBY3uXuisG4hzoIQbpHHkzaYiQew==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.35.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
|
||||||
@@ -4320,9 +4317,6 @@ 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}
|
||||||
|
|
||||||
@@ -10808,7 +10802,7 @@ snapshots:
|
|||||||
|
|
||||||
'@formatjs/intl-utils@2.3.0': {}
|
'@formatjs/intl-utils@2.3.0': {}
|
||||||
|
|
||||||
'@gpustack/core-ui@1.0.32(czdvzceysqw7iv6pct2ucnb23e)':
|
'@gpustack/core-ui@1.0.35(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)
|
||||||
@@ -14554,8 +14548,6 @@ 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 |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 640 B |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 801 B |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
// 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;
|
||||||
@@ -28,6 +28,7 @@ export default {
|
|||||||
rowSelectedBg: 'transparent',
|
rowSelectedBg: 'transparent',
|
||||||
headerSortActiveBg: 'transparent',
|
headerSortActiveBg: 'transparent',
|
||||||
headerSortHoverBg: 'transparent',
|
headerSortHoverBg: 'transparent',
|
||||||
|
bodySortBg: 'transparent',
|
||||||
headerBg: 'none'
|
headerBg: 'none'
|
||||||
},
|
},
|
||||||
Button: {
|
Button: {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default {
|
|||||||
rowSelectedBg: 'transparent',
|
rowSelectedBg: 'transparent',
|
||||||
headerSortActiveBg: 'transparent',
|
headerSortActiveBg: 'transparent',
|
||||||
headerSortHoverBg: 'transparent',
|
headerSortHoverBg: 'transparent',
|
||||||
|
bodySortBg: 'transparent',
|
||||||
headerSplitColor: '#e8e8e8',
|
headerSplitColor: '#e8e8e8',
|
||||||
headerBg: 'none'
|
headerBg: 'none'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
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)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -52,21 +52,6 @@ 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',
|
||||||
@@ -269,11 +254,6 @@ 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]);
|
||||||
@@ -469,15 +449,11 @@ export default (props: any) => {
|
|||||||
unAccessible={runtimeConfig?.unAccessible}
|
unAccessible={runtimeConfig?.unAccessible}
|
||||||
noAccessible={runtimeConfig?.noAccessible}
|
noAccessible={runtimeConfig?.noAccessible}
|
||||||
>
|
>
|
||||||
{isNoContainerPage ? (
|
<PageContainerInner>
|
||||||
<Outlet />
|
<div>
|
||||||
) : (
|
<Outlet />
|
||||||
<PageContainerInner>
|
</div>
|
||||||
<div>
|
</PageContainerInner>
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</PageContainerInner>
|
|
||||||
)}
|
|
||||||
</Exception>
|
</Exception>
|
||||||
</div>
|
</div>
|
||||||
{NoResourceModal}
|
{NoResourceModal}
|
||||||
|
|||||||
@@ -267,6 +267,10 @@ 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',
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ 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',
|
||||||
|
|||||||
@@ -266,6 +266,10 @@ 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',
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ 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': '一般ユーザー',
|
||||||
|
|||||||
@@ -265,6 +265,10 @@ 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': 'Введите пароль',
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ 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': 'Пользователь',
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ 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',
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ 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ı',
|
||||||
|
|||||||
@@ -257,6 +257,9 @@ 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': '请输入密码',
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ 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,5 +1,5 @@
|
|||||||
import useCoolColors from '@/hooks/use-cool-colors';
|
import useCoolColors from '@/hooks/use-cool-colors';
|
||||||
import { Chart } from '@gpustack/core-ui';
|
import { Chart } from '@gpustack/core-ui/charts';
|
||||||
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';
|
||||||
|
|||||||
@@ -4,28 +4,53 @@ import {
|
|||||||
RouteContext,
|
RouteContext,
|
||||||
type PageContainerProps
|
type PageContainerProps
|
||||||
} from '@ant-design/pro-components';
|
} from '@ant-design/pro-components';
|
||||||
import { useOverlayScroller } from '@gpustack/core-ui';
|
import {
|
||||||
|
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 { useContext, useEffect, useRef } from 'react';
|
import {
|
||||||
|
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, leftContent, rightContent, ...rest }) => {
|
> = ({ children, styles, title, ...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) {
|
||||||
@@ -34,43 +59,85 @@ 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 (
|
||||||
<div className={pageBoxCss.containerWrapper}>
|
<HeaderSlotContext.Provider value={slotValue}>
|
||||||
<PageContainer
|
<div className={pageBoxCss.containerWrapper}>
|
||||||
{...rest}
|
<PageContainer
|
||||||
fixedHeader={false}
|
{...rest}
|
||||||
title={false}
|
fixedHeader={false}
|
||||||
pageHeaderRender={false}
|
title={false}
|
||||||
style={{
|
pageHeaderRender={false}
|
||||||
flex: 1
|
style={{
|
||||||
}}
|
flex: 1
|
||||||
token={{
|
}}
|
||||||
paddingInlinePageContainerContent: paddingInlinePageContainerContent
|
token={{
|
||||||
}}
|
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}
|
|
||||||
>
|
>
|
||||||
{children}
|
<div className={pageBoxCss.title}>
|
||||||
</div>
|
<div className={pageBoxCss.left}>
|
||||||
</PageContainer>
|
<div ref={setLeftEl} className={pageBoxCss.leftSlot} />
|
||||||
</div>
|
<span className={pageBoxCss.defaultTitle}>
|
||||||
|
{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,5 +1,5 @@
|
|||||||
import useCoolColors from '@/hooks/use-cool-colors';
|
import useCoolColors from '@/hooks/use-cool-colors';
|
||||||
import { Chart } from '@gpustack/core-ui';
|
import { Chart } from '@gpustack/core-ui/charts';
|
||||||
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,14 +70,44 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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, useSearchParams } from '@umijs/max';
|
import { useIntl } 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,7 +12,6 @@ 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'] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { PageContainerInner } from '../_components/page-box';
|
import { HeaderLeft } 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,9 +122,10 @@ const Details: React.FC = () => {
|
|||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainerInner
|
<>
|
||||||
leftContent={<PageBreadcrumb items={breadcrumbItems} />}
|
<HeaderLeft>
|
||||||
>
|
<PageBreadcrumb items={breadcrumbItems} />
|
||||||
|
</HeaderLeft>
|
||||||
<DetailContext.Provider
|
<DetailContext.Provider
|
||||||
value={{
|
value={{
|
||||||
detailData: detailData || {},
|
detailData: detailData || {},
|
||||||
@@ -164,7 +165,7 @@ const Details: React.FC = () => {
|
|||||||
onCancel={closeViewLogsModal}
|
onCancel={closeViewLogsModal}
|
||||||
></ViewLogsModal>
|
></ViewLogsModal>
|
||||||
<DeleteModal ref={modalRef}></DeleteModal>
|
<DeleteModal ref={modalRef}></DeleteModal>
|
||||||
</PageContainerInner>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { IconFont } from '@gpustack/core-ui';
|
import { BaseSelect, 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 { useAtomValue } from 'jotai';
|
import { useEffect } from 'react';
|
||||||
import { PageContainerInner } from '../_components/page-box';
|
import { HeaderLeft } 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,7 +17,18 @@ 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 clusterDetailData = useAtomValue(clusterDetailAtom);
|
const { clusterList, fetchClusterList, cancelRequest } =
|
||||||
|
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 = [
|
||||||
{
|
{
|
||||||
@@ -25,10 +36,27 @@ const ClusterDetailModal = () => {
|
|||||||
onClick: () => navigate(-1)
|
onClick: () => navigate(-1)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: clusterName
|
title: (
|
||||||
|
<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
|
||||||
@@ -46,11 +74,10 @@ const ClusterDetailModal = () => {
|
|||||||
) ?? []) as TabItem[];
|
) ?? []) as TabItem[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainerInner
|
<>
|
||||||
header={{
|
<HeaderLeft>
|
||||||
title: <PageBreadcrumb items={breadcrumbItems} />
|
<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
|
||||||
@@ -63,19 +90,25 @@ 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 clusterId={Number(id)} source="clusterDetail" />
|
<WorkerList
|
||||||
|
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: <GPUList clusterId={Number(id)} source="clusterDetail" />
|
children: (
|
||||||
|
<GPUList key={id} clusterId={Number(id)} source="clusterDetail" />
|
||||||
|
)
|
||||||
},
|
},
|
||||||
...extraTabs
|
...extraTabs
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</PageContainerInner>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { GaugeChart, TemplateCard } from '@gpustack/core-ui';
|
import { 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';
|
import { LineChart } from '@gpustack/core-ui/charts';
|
||||||
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';
|
import { LineChart } from '@gpustack/core-ui/charts';
|
||||||
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,10 +1,6 @@
|
|||||||
import { queryClusterList } from '@/pages/cluster-management/apis';
|
import { queryClusterList } from '@/pages/cluster-management/apis';
|
||||||
import {
|
import { BaseSelect, CardWrapper, PageTools } from '@gpustack/core-ui';
|
||||||
BaseSelect,
|
import { GaugeChart } from '@gpustack/core-ui/charts';
|
||||||
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';
|
import { HBarChart } from '@gpustack/core-ui/charts';
|
||||||
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';
|
import { Chart } from '@gpustack/core-ui/charts';
|
||||||
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: ['date', 'route'],
|
group_by: ['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: ['date', 'route'],
|
group_by: ['route'],
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
sort_by: '-api_requests'
|
sort_by: '-api_requests'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { formatLargeNumber } from '@/utils';
|
import { formatLargeNumber } from '@/utils';
|
||||||
import { CardWrapper, MixLineBarChart, SimpleCard } from '@gpustack/core-ui';
|
import { CardWrapper, 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,4 +1,5 @@
|
|||||||
import { CardWrapper, HBarChart } from '@gpustack/core-ui';
|
import { CardWrapper } 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: ['date', 'user'],
|
group_by: ['user'],
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
sort_by: '-total_tokens'
|
sort_by: '-total_tokens'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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';
|
||||||
@@ -14,6 +15,7 @@ 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,
|
||||||
@@ -33,7 +35,8 @@ 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';
|
||||||
|
|
||||||
const imageLogoMap = {
|
// Light theme logos
|
||||||
|
const imageLogoLightMap = {
|
||||||
vllm: vllmLogo,
|
vllm: vllmLogo,
|
||||||
sglang: sgLangLogo,
|
sglang: sgLangLogo,
|
||||||
jupyter: jupyterLogo,
|
jupyter: jupyterLogo,
|
||||||
@@ -42,25 +45,31 @@ const imageLogoMap = {
|
|||||||
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 imageLogoMap | null = null;
|
let matched: keyof typeof logoMap | null = null;
|
||||||
let earliest = Infinity;
|
let earliest = Infinity;
|
||||||
(Object.keys(imageLogoMap) as Array<keyof typeof imageLogoMap>).forEach(
|
(Object.keys(logoMap) as Array<keyof typeof logoMap>).forEach((key) => {
|
||||||
(key) => {
|
const idx = lower.indexOf(key);
|
||||||
const idx = lower.indexOf(key);
|
if (idx !== -1 && idx < earliest) {
|
||||||
if (idx !== -1 && idx < earliest) {
|
earliest = idx;
|
||||||
earliest = idx;
|
matched = key;
|
||||||
matched = key;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
return matched
|
return matched
|
||||||
? {
|
? {
|
||||||
logo: imageLogoMap[matched],
|
logo: logoMap[matched],
|
||||||
type: matched
|
type: matched
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -135,6 +144,7 @@ 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(
|
||||||
@@ -158,7 +168,7 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderLogo = () => {
|
const renderLogo = () => {
|
||||||
const imageLogo = matchImageLogo(data.spec?.image);
|
const imageLogo = matchImageLogo(data.spec?.image, isDarkTheme);
|
||||||
if (imageLogo?.logo) {
|
if (imageLogo?.logo) {
|
||||||
return (
|
return (
|
||||||
<LogoImg
|
<LogoImg
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
@@ -19,12 +20,17 @@ const Header = styled.div`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ItemInfo = styled.div`
|
const Metric: React.FC<{ label: React.ReactNode; value: React.ReactNode }> = ({
|
||||||
display: flex;
|
label,
|
||||||
flex-wrap: wrap;
|
value
|
||||||
white-space: break-spaces;
|
}) => (
|
||||||
font-size: 13px;
|
<span style={{ lineHeight: 1.2 }}>
|
||||||
`;
|
{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;
|
||||||
@@ -64,29 +70,20 @@ const GPUCard: React.FC<{
|
|||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
info || (
|
info || (
|
||||||
<>
|
<Flex wrap gap={8} style={{ fontSize: 13 }}>
|
||||||
<ItemInfo>
|
<Metric
|
||||||
{intl.formatMessage({ id: 'resources.table.vram' })}(
|
label={intl.formatMessage({ id: 'resources.table.total' })}
|
||||||
{intl.formatMessage({ id: 'resources.table.used' })}/
|
value={convertFileSize(data?.memory?.total || 0)}
|
||||||
{intl.formatMessage({ id: 'resources.table.total' })}):{' '}
|
/>
|
||||||
<span>
|
<Metric
|
||||||
{convertFileSize(
|
label={intl.formatMessage({ id: 'resources.table.used' })}
|
||||||
data?.memory?.used || data?.memory?.allocated || 0
|
value={convertFileSize(data?.memory?.used || 0)}
|
||||||
)}{' '}
|
/>
|
||||||
/ {convertFileSize(data?.memory?.total || 0)}
|
<Metric
|
||||||
</span>
|
label={intl.formatMessage({ id: 'resources.table.allocated' })}
|
||||||
</ItemInfo>
|
value={convertFileSize(data?.memory?.allocated || 0)}
|
||||||
{/* <ItemInfo>
|
/>
|
||||||
<span>
|
</Flex>
|
||||||
{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>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
IconFont,
|
|
||||||
StatusTag,
|
StatusTag,
|
||||||
TagsWrapper,
|
TagsWrapper,
|
||||||
TemplateCard,
|
TemplateCard,
|
||||||
@@ -20,6 +19,7 @@ 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,6 +84,14 @@ 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;
|
||||||
@@ -176,10 +184,7 @@ const ModelItem: React.FC<{
|
|||||||
header={
|
header={
|
||||||
<Header>
|
<Header>
|
||||||
<span className="text gap-8">
|
<span className="text gap-8">
|
||||||
<IconFont
|
<ModelLogo src={getModelLogo(model.name)} alt="" />
|
||||||
type={sourceIconMap[model.source]}
|
|
||||||
style={{ fontSize: 24 }}
|
|
||||||
/>
|
|
||||||
<span>{model.name}</span>
|
<span>{model.name}</span>
|
||||||
</span>
|
</span>
|
||||||
<StatusTag
|
<StatusTag
|
||||||
|
|||||||
@@ -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, useRef } from 'react';
|
import { useEffect, useMemo } 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,6 +24,7 @@ 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;
|
||||||
@@ -116,7 +117,6 @@ 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,37 +139,36 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
|
|||||||
});
|
});
|
||||||
}, [clusterList, scopeOrgId]);
|
}, [clusterList, scopeOrgId]);
|
||||||
|
|
||||||
// On a genuine org change (not the initial value — the modal's open
|
// Keep the cluster selection consistent with the available (scoped)
|
||||||
// handler seeds the first cluster), drop a now-out-of-scope cluster and
|
// options. The modal's open handler seeds a cluster, but that seed can be
|
||||||
// re-pick within the new org so GPU/backend options refetch for it.
|
// empty (options not loaded yet) or point outside the current scope (the
|
||||||
|
// 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 (prevScopeRef.current === undefined) {
|
if (!clusterOptions?.length) {
|
||||||
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 (stillValid) {
|
if (current != null && 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;
|
||||||
form.setFieldValue('cluster_id', next ?? null);
|
if (next == null || next === current) {
|
||||||
if (next != null) {
|
return;
|
||||||
handleClusterChange?.(next as number);
|
|
||||||
}
|
}
|
||||||
// The prevScopeRef guard above makes this a no-op unless scopeOrgId
|
form.setFieldValue('cluster_id', next);
|
||||||
// actually changed, so listing the other deps is safe (no extra runs).
|
handleClusterChange?.(next);
|
||||||
}, [scopeOrgId, clusterOptions, form, handleClusterChange]);
|
}, [clusterOptions, form, handleClusterChange]);
|
||||||
|
|
||||||
const clusterOptionRender = (option: any) => {
|
const clusterOptionRender = (option: any) => {
|
||||||
const { data } = option;
|
const { data } = option;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { PageContainerInner } from '@/pages/_components/page-box';
|
import {
|
||||||
|
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';
|
||||||
@@ -105,15 +108,11 @@ const LLModels: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [activeKey]);
|
}, [activeKey]);
|
||||||
|
|
||||||
|
usePageContentStyle({ padding: 0 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainerInner
|
<>
|
||||||
leftContent={title}
|
<HeaderLeft>{title}</HeaderLeft>
|
||||||
styles={{
|
|
||||||
containerWrapper: {
|
|
||||||
padding: 0
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeploymentsContext.Provider
|
<DeploymentsContext.Provider
|
||||||
value={{
|
value={{
|
||||||
generateFormValues,
|
generateFormValues,
|
||||||
@@ -138,7 +137,7 @@ const LLModels: React.FC = () => {
|
|||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
></Tabs>
|
></Tabs>
|
||||||
</DeploymentsContext.Provider>
|
</DeploymentsContext.Provider>
|
||||||
</PageContainerInner>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.cascader-popup-wrapper.gpu-selector {
|
||||||
|
.ant-cascader-menu-item-content {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// 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];
|
||||||
|
};
|
||||||