Compare commits

...
Author SHA1 Message Date
gitlawr 207cef350f refactor(models): drop redundant effect dep and cast
Address review: scopeOrgId is already folded into clusterOptions (its
useMemo dep), so the effect still fires on scope changes without listing
it; and the next == null guard narrows next to number, so the cast goes.
2026-07-07 16:32:26 +08:00
gitlawr fa3fed8671 fix(models): preselect the scoped default cluster on deploy
The deploy form's cluster select could open blank even when a valid
(default) cluster exists for the current scope. The open handler seeds
the cluster once, but that seed can be empty (options not loaded yet) or
point outside the resolved scope (the scope id and the option list both
settle after mount, and switching scope re-scopes the list), and nothing
re-picked afterwards — so the field stayed empty.

Make the form enforce the invariant directly: whenever the scope or the
available options change, if the current selection is empty or not among
the options, fall back to the scope's default cluster (then a Ready one,
then the first). A still-valid selection is left untouched.
2026-07-07 16:23:38 +08:00
jialinandjialin dc41b61d69 fix(deployments): display gpu allocated value in gpu selector 2026-07-06 17:48:22 +08:00
jialinandjialin 3a1565690d fix(worker): incorrect worker version 2026-07-06 11:58:51 +08:00
jialinandjialin 1bb9fd73db chore(template): theme logo 2026-07-06 11:58:51 +08:00
jialinandjialin 39024b1d46 chore(hooks): remove unused hooks 2026-07-06 11:58:51 +08:00
hibigandjialin 4ecaa0a326 chore: bump @gpustack/core-ui to v1.0.35 2026-07-02 13:23:14 +08:00
jialinandjialin e1af27e11c fix(dashboard): remove date from group_by 2026-07-02 13:18:04 +08:00
jialinandjialin f4ebd8d7d1 chore: remove old code 2026-07-02 13:18:04 +08:00
jialinandjialin bd58a911d5 chore: remove driver.js 2026-07-02 13:18:04 +08:00
jialinandjialin 5321973ebe perf(charts): import chart components from @gpustack/core-ui/charts
Switch all echarts-based chart imports to the dedicated core-ui charts
entry so echarts stays out of the synchronous entry bundle and loads
only on chart routes. Requires @gpustack/core-ui with the ./charts export.
2026-07-01 20:54:26 +08:00
jialinandjialin 0a1f875590 perf(markdown): load katex css lazily via local FullMarkdown wrapper
core-ui no longer bundles katex.min.css (it would base64-inline ~1.4MB
of fonts into the render-blocking global css). Wrap FullMarkdown locally
to co-locate the katex stylesheet so it loads only in the routes that
render math.
2026-07-01 20:54:26 +08:00
hibigandjialin ea2c9870a7 chore: bump @gpustack/core-ui to v1.0.34 2026-07-01 20:48:55 +08:00
jialinandjialin 23f0e4398a fix(pageContainer): remove leftContent, rightContent, props 2026-07-01 19:11:47 +08:00
jialinandjialin b342174fc2 feat(cluster-detail): add cluster switcher; consume core-ui header-slot
page-box now imports the header-slot bridge from core-ui and re-exports it, plus a dev warning when a header slot has multiple owners. cluster-detail breadcrumb becomes a BaseSelect to switch clusters, and the tab tables are keyed by id so they refetch on switch.
2026-07-01 19:11:47 +08:00
jialinandjialin d07d3b2c99 refactor(layout): adopt header-slot page container across pages 2026-07-01 19:11:47 +08:00
hibigandjialin 9d0a5d01b0 chore: bump @gpustack/core-ui to v1.0.33 2026-07-01 19:08:52 +08:00
gitlawrandjialin 1e97d29fca feat(login): surface SSO callback failures via error query param
The CAS / OIDC / SAML callbacks now redirect to `/login?error=<code>`
on failure instead of letting the browser land on a raw JSON error
page, so the actionable copy reaches the user. Two codes are
recognised:

* `source_conflict` — incoming SSO username collides with an existing
  account from a different source. Message points the user at an
  administrator to link or convert.
* `auth_failed` — anything else (bad ticket, expired state, IdP
  unreachable, malformed response). Generic message: try again or
  contact the administrator.

On mount the login form picks up the `?error=` query param, maps it
through a small `messageIdByCode` table to an i18n key, and routes
the result through the existing auth-error toast. Unknown codes are
silently ignored so a future server release adding a code doesn't
render a bare key. The query param is cleared via
`history.replaceState` so a refresh doesn't re-fire the toast.

Strings added to all five locales.
2026-07-01 15:39:10 +08:00
gitlawrandjialin 350f398cde feat(users): add authentication source dropdown to the user form
The add / edit user drawer now exposes a Source select (Local / OIDC /
SAML / CAS) so an admin can flip an existing account between Local
password and an external IdP without touching the database. Mirrors
the matching `PUT /v1/users/{id}` change on the backend.

Password field follows the selected source:

* Hidden when source != Local — those users authenticate via the IdP
  and a local password row would be a /login bypass.
* Required when CREATE-with-Local, or when EDIT is switching an SSO
  user back to Local (the backend rejects SSO -> Local without a
  fresh password to avoid locking the user out of /login).
* Optional when editing an already-Local user, matching today's
  behaviour.

A switch in EDIT mode surfaces a tip explaining the side effect
(password cleared / new password required) so the consequence isn't
hidden. The Source select is disabled on self-edit — same guard the
role column already uses — so an admin can't lock themselves out by
flipping their own row to an external source.

Strings are added to all five locales; the IdP protocol acronyms
(OIDC / SAML / CAS) render verbatim and don't need translation keys.
2026-07-01 15:39:10 +08:00
jialinandjialin 8a15172316 fix(provider): table sorting does not work 2026-07-01 11:42:30 +08:00
jialinandjialin 185e3475e7 feat: add model icon in my models 2026-07-01 11:42:30 +08:00
jialinandjialin 64720ccbfa chore: press enter key to submit while focus on password 2026-07-01 11:42:30 +08:00
jialinandjialin db17192e55 fix(usage): carry full filter set in breakdown tables and fix double fetch
- breakdown sub-tables now send all active filters (route/user/api_key), matching the trend chart
- summary tab filters the token trend by user and unions user options from both meta APIs (deduped by id)
- stabilize the filters reference so meta load no longer retriggers a second fetch on mount
2026-06-30 17:02:17 +08:00
gitlawr a1dd1ec861 ci: trigger backend pack on UI merge
After build-publish uploads the UI tarball to COS, dispatch a
repository_dispatch event (ui-built) to gpustack/gpustack so the
backend repack picks up the new UI.

Skipped for tag pushes — release tags drive the backend pack via
its own tag trigger. Requires a DISPATCH_PAT secret with
actions:write on gpustack/gpustack.
2026-06-30 15:57:33 +08:00
125 changed files with 839 additions and 1365 deletions
+18
View File
@@ -1,6 +1,7 @@
name: CI
on:
workflow_dispatch: {}
push:
branches:
- 'main'
@@ -117,3 +118,20 @@ jobs:
remote_path: releases/${{ steps.version.outputs.version }}.tar.gz
accelerate: true
clean: false
trigger-backend:
needs: build-publish
if: (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-22.04
steps:
- name: Dispatch backend build
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.DISPATCH_PAT }}
repository: gpustack/gpustack
event-type: ui-built
client-payload: |
{
"ref": "${{ github.ref }}",
"sha": "${{ github.sha }}"
}
+11
View File
@@ -88,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.
## 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
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/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
+1 -2
View File
@@ -17,7 +17,7 @@
"@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.32",
"@gpustack/core-ui": "^1.0.35",
"@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6",
@@ -39,7 +39,6 @@
"culori": "^4.0.2",
"dayjs": "^1.11.11",
"dompurify": "^3.2.6",
"driver.js": "^1.3.1",
"echarts": "^5.5.1",
"file-saver": "^2.0.5",
"has-ansi": "^5.0.1",
+5 -13
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1
version: 7.1.2
'@gpustack/core-ui':
specifier: ^1.0.32
version: 1.0.32(czdvzceysqw7iv6pct2ucnb23e)
specifier: ^1.0.35
version: 1.0.35(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf':
specifier: ^0.1.7
version: 0.1.18
@@ -89,9 +89,6 @@ importers:
dompurify:
specifier: ^3.2.6
version: 3.4.2
driver.js:
specifier: ^1.3.1
version: 1.4.0
echarts:
specifier: ^5.5.1
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}
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':
resolution: {integrity: sha512-kGTazoqbK2KyZgOP6gmQaRxTiQVfF2IKLGDXjJq6w6BbmJgALXFJA2v2ROjAbjEVyfTdBzyYeXfPo/JgISpMNw==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.32.tgz}
'@gpustack/core-ui@1.0.35':
resolution: {integrity: sha512-MaEmCM3FikeKZdUgDSyUtIfXtNMbNOLeFiTynl6DcKEraUOpk6tgQXPDginBY3uXuisG4hzoIQbpHHkzaYiQew==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.35.tgz}
peerDependencies:
'@ant-design/icons': ^6.1.0
'@ant-design/pro-components': 3.1.0-0
@@ -4320,9 +4317,6 @@ packages:
dot-case@3.0.4:
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:
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': {}
'@gpustack/core-ui@1.0.32(czdvzceysqw7iv6pct2ucnb23e)':
'@gpustack/core-ui@1.0.35(czdvzceysqw7iv6pct2ucnb23e)':
dependencies:
'@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)
@@ -14554,8 +14548,6 @@ snapshots:
no-case: 3.0.4
tslib: 2.8.1
driver.js@1.4.0: {}
duck@0.1.12:
dependencies:
underscore: 1.13.8

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+12
View File
@@ -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;
+1
View File
@@ -28,6 +28,7 @@ export default {
rowSelectedBg: 'transparent',
headerSortActiveBg: 'transparent',
headerSortHoverBg: 'transparent',
bodySortBg: 'transparent',
headerBg: 'none'
},
Button: {
+1
View File
@@ -31,6 +31,7 @@ export default {
rowSelectedBg: 'transparent',
headerSortActiveBg: 'transparent',
headerSortHoverBg: 'transparent',
bodySortBg: 'transparent',
headerSplitColor: '#e8e8e8',
headerBg: 'none'
},
-10
View File
@@ -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)
}));
}
-58
View File
@@ -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;
-18
View File
@@ -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 };
};
-56
View File
@@ -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
};
}
-23
View File
@@ -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;
-71
View File
@@ -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;
-136
View File
@@ -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
};
}
-35
View File
@@ -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;
-106
View File
@@ -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 };
}
-72
View File
@@ -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
};
}
-280
View File
@@ -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
};
}
+5 -29
View File
@@ -52,21 +52,6 @@ import { ExtraContent } from './extraRender';
import { patchRoutes } from './runtime';
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 = [
'/resources/workers',
'/resources/clusters/list',
@@ -269,11 +254,6 @@ export default (props: any) => {
[location.pathname]
);
const isNoContainerPage = useMemo(() => {
// @ts-ignore
return NO_CONTAINER_PAGES.includes(matchedRoute?.name as string);
}, [matchedRoute]);
const collapsed = useMemo(() => {
return userSettings.collapsed || false;
}, [userSettings.collapsed]);
@@ -469,15 +449,11 @@ export default (props: any) => {
unAccessible={runtimeConfig?.unAccessible}
noAccessible={runtimeConfig?.noAccessible}
>
{isNoContainerPage ? (
<Outlet />
) : (
<PageContainerInner>
<div>
<Outlet />
</div>
</PageContainerInner>
)}
<PageContainerInner>
<div>
<Outlet />
</div>
</PageContainerInner>
</Exception>
</div>
{NoResourceModal}
+4
View File
@@ -267,6 +267,10 @@ export default {
'common.select.count': '{count} selected',
'common.login.auth': 'Authenticating...',
'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.username.holder': 'Please enter username',
'common.login.password.holder': 'Please enter password',
+5
View File
@@ -12,6 +12,11 @@ export default {
'users.form.active.description': 'Enable or disable this user account',
'users.form.fullname': 'Full Name',
'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.form.admin': 'Admin',
'users.form.user': 'User',
+4
View File
@@ -266,6 +266,10 @@ export default {
'common.select.count': '{count} selected',
'common.login.auth': 'Authenticating...',
'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.username.holder': 'Please enter username',
'common.login.password.holder': 'Please enter password',
+5
View File
@@ -13,6 +13,11 @@ export default {
'このユーザーアカウントを有効または無効にする',
'users.form.fullname': 'フルネーム',
'users.form.source': 'ソース',
'users.form.source.local': 'ローカル',
'users.form.source.tip.switchToLocal':
'ローカルに切り替えるには新しいパスワードが必要です。以後、ユーザーは標準のログインフォームからサインインします。',
'users.form.source.tip.switchToExternal':
'外部ソースに切り替えるとユーザーのローカルパスワードが削除され、設定済みの ID プロバイダーからサインインするようになります。',
'users.table.user': 'ユーザー',
'users.form.admin': '管理者',
'users.form.user': '一般ユーザー',
+4
View File
@@ -265,6 +265,10 @@ export default {
'common.select.count': '{count} Выбрано',
'common.login.auth': 'Аутентификация...',
'common.login.auth.failed': 'Ошибка аутентификации',
'common.login.error.source_conflict':
'Учётная запись с таким именем уже существует, но с другим источником аутентификации. Обратитесь к администратору для связывания или преобразования.',
'common.login.error.auth_failed':
'Не удалось пройти аутентификацию через провайдера идентификации. Попробуйте ещё раз или обратитесь к администратору.',
'common.login.password': 'Войти с паролем',
'common.login.username.holder': 'Введите имя пользователя',
'common.login.password.holder': 'Введите пароль',
+5
View File
@@ -13,6 +13,11 @@ export default {
'Включить или отключить эту учетную запись пользователя',
'users.form.fullname': 'Полное имя',
'users.form.source': 'Источник',
'users.form.source.local': 'Локальный',
'users.form.source.tip.switchToLocal':
'Переключение на «Локальный» требует ввода нового пароля. После этого пользователь будет входить через стандартную форму входа.',
'users.form.source.tip.switchToExternal':
'Переключение на внешний источник удаляет локальный пароль пользователя. После этого вход будет выполняться через настроенного провайдера идентификации.',
'users.table.user': 'пользователи',
'users.form.admin': 'Администратор',
'users.form.user': 'Пользователь',
+4
View File
@@ -269,6 +269,10 @@ export default {
'common.select.count': '{count} seçildi',
'common.login.auth': 'Kimlik doğrulanıyor...',
'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.username.holder': 'Lütfen kullanıcı adını girin',
'common.login.password.holder': 'Lütfen şifreyi girin',
+5
View File
@@ -13,6 +13,11 @@ export default {
'Bu kullanıcı hesabını etkinleştir veya devre dışı bırak',
'users.form.fullname': 'Tam Ad',
'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.form.admin': 'Yönetici',
'users.form.user': 'Kullanıcı',
+3
View File
@@ -257,6 +257,9 @@ export default {
'common.select.count': '已选 {count} 项',
'common.login.auth': '认证中...',
'common.login.auth.failed': '认证失败',
'common.login.error.source_conflict':
'已存在同名账号但来源不同。请联系管理员关联或转换该账号。',
'common.login.error.auth_failed': '身份提供商认证失败。请重试或联系管理员。',
'common.login.password': '使用密码登录',
'common.login.username.holder': '请输入用户名',
'common.login.password.holder': '请输入密码',
+5
View File
@@ -12,6 +12,11 @@ export default {
'users.form.active.description': '启用或禁用此用户账户',
'users.form.fullname': '全名',
'users.form.source': '来源',
'users.form.source.local': '本地',
'users.form.source.tip.switchToLocal':
'切换到本地需要设置新密码,之后用户将通过标准登录表单登录。',
'users.form.source.tip.switchToExternal':
'切换到外部来源会清除该用户的本地密码,之后用户将通过所配置的身份提供商登录。',
'users.table.user': '用户',
'users.form.admin': '管理员',
'users.form.user': '普通用户',
+1 -1
View File
@@ -1,5 +1,5 @@
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 { Empty, Spin, theme } from 'antd';
import _ from 'lodash';
+107 -40
View File
@@ -4,28 +4,53 @@ import {
RouteContext,
type PageContainerProps
} 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 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';
// 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;
export const PageContainerInner: React.FC<
PageContainerProps & {
leftContent?: React.ReactNode;
rightContent?: React.ReactNode;
styles?: {
containerWrapper?: React.CSSProperties;
};
}
> = ({ children, styles, title, leftContent, rightContent, ...rest }) => {
> = ({ children, styles, title, ...rest }) => {
const { initialize: initialize } = useOverlayScroller({
defer: false
});
const pageContext = useContext(RouteContext);
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(() => {
if (contentWrapperRef.current) {
@@ -34,43 +59,85 @@ export const PageContainerInner: React.FC<
}
}, [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 (
<div className={pageBoxCss.containerWrapper}>
<PageContainer
{...rest}
fixedHeader={false}
title={false}
pageHeaderRender={false}
style={{
flex: 1
}}
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}
<HeaderSlotContext.Provider value={slotValue}>
<div className={pageBoxCss.containerWrapper}>
<PageContainer
{...rest}
fixedHeader={false}
title={false}
pageHeaderRender={false}
style={{
flex: 1
}}
token={{
paddingInlinePageContainerContent: paddingInlinePageContainerContent
}}
>
{children}
</div>
</PageContainer>
</div>
<div className={pageBoxCss.title}>
<div className={pageBoxCss.left}>
<div ref={setLeftEl} className={pageBoxCss.leftSlot} />
<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 -1
View File
@@ -1,5 +1,5 @@
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 { Empty, Spin, theme } from 'antd';
import React, {
@@ -70,14 +70,44 @@
height: 56px;
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 {
display: flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;
}
.right {
display: flex;
align-items: center;
flex-shrink: 0;
}
.leftSlot,
.rightSlot {
display: contents;
}
.defaultTitle {
display: inline-flex;
align-items: center;
}
// Portal-driven visibility: when a page's HeaderLeft / HeaderRight
// portal target has children, hide the default title / show the divider.
// Using `:empty` (rather than React state) keeps visibility synchronous
// with the DOM mutation, so there is no inter-frame flicker on route
// transitions.
.leftSlot:not(:empty) ~ .defaultTitle {
display: none;
}
.rightSlot:empty ~ .divider {
display: none;
}
}
}
-24
View File
@@ -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 { AutoTooltip } from '@gpustack/core-ui';
import { FullMarkdown } from '@gpustack/core-ui/markdown';
import { useIntl } from '@umijs/max';
import { Button, Tag } from 'antd';
import _ from 'lodash';
@@ -1,5 +1,5 @@
import { IconFont } from '@gpustack/core-ui';
import { useIntl, useSearchParams } from '@umijs/max';
import { useIntl } from '@umijs/max';
import { Tabs, TabsProps } from 'antd';
import React, { useState } from 'react';
import Environment from './environment';
@@ -12,7 +12,6 @@ const Details: React.FC<{
}> = ({ tabBarExtraContent }) => {
const intl = useIntl();
const [activeKey, setActiveKey] = useState('summary');
const [searchParams] = useSearchParams();
const items: TabsProps['items'] = [
{
+6 -5
View File
@@ -2,7 +2,7 @@ import { BaseSelect, DeleteModal } from '@gpustack/core-ui';
import { useIntl, useNavigate, useSearchParams } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
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 { deleteBenchmark } from './apis';
import DetailContent from './components/detail-content';
@@ -122,9 +122,10 @@ const Details: React.FC = () => {
}, [id]);
return (
<PageContainerInner
leftContent={<PageBreadcrumb items={breadcrumbItems} />}
>
<>
<HeaderLeft>
<PageBreadcrumb items={breadcrumbItems} />
</HeaderLeft>
<DetailContext.Provider
value={{
detailData: detailData || {},
@@ -164,7 +165,7 @@ const Details: React.FC = () => {
onCancel={closeViewLogsModal}
></ViewLogsModal>
<DeleteModal ref={modalRef}></DeleteModal>
</PageContainerInner>
</>
);
};
+47 -14
View File
@@ -1,15 +1,15 @@
import { clusterDetailAtom } from '@/atoms/clusters';
import GPUList from '@/pages/resources/components/gpus';
import WorkerList from '@/pages/resources/components/workers';
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 { Tabs, type TabsProps } from 'antd';
import { useAtomValue } from 'jotai';
import { PageContainerInner } from '../_components/page-box';
import { useEffect } from 'react';
import { HeaderLeft } from '../_components/page-box';
import PageBreadcrumb from '../_components/page-breadcrumb';
import ClusterBasic from './components/detail/cluster-basic';
import ClusterSystemLoad from './components/detail/cluster-system-load';
import { useQueryClusterList } from './services/use-query-cluster-list';
const ClusterDetailModal = () => {
const navigate = useNavigate();
@@ -17,7 +17,18 @@ const ClusterDetailModal = () => {
const [searchParams] = useSearchParams();
const id = searchParams.get('id');
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 = [
{
@@ -25,10 +36,27 @@ const ClusterDetailModal = () => {
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
// items (e.g. per-cluster access / quota panels) by exporting
// ``clusterDetail.extraTabs(clusterId, intl)`` that returns an
@@ -46,11 +74,10 @@ const ClusterDetailModal = () => {
) ?? []) as TabItem[];
return (
<PageContainerInner
header={{
title: <PageBreadcrumb items={breadcrumbItems} />
}}
>
<>
<HeaderLeft>
<PageBreadcrumb items={breadcrumbItems} />
</HeaderLeft>
<ClusterBasic clusterId={Number(id)}></ClusterBasic>
<ClusterSystemLoad clusterId={Number(id)}></ClusterSystemLoad>
<Tabs
@@ -63,19 +90,25 @@ const ClusterDetailModal = () => {
label: intl.formatMessage({ id: 'resources.nodes' }),
icon: <IconFont type="icon-resources" />,
children: (
<WorkerList clusterId={Number(id)} source="clusterDetail" />
<WorkerList
key={id}
clusterId={Number(id)}
source="clusterDetail"
/>
)
},
{
key: 'gpus',
label: intl.formatMessage({ id: 'menu.resources.gpus' }),
icon: <IconFont type="icon-gpu1" />,
children: <GPUList clusterId={Number(id)} source="clusterDetail" />
children: (
<GPUList key={id} clusterId={Number(id)} source="clusterDetail" />
)
},
...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 { Col, Row } from 'antd';
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 dayjs from 'dayjs';
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 dayjs from 'dayjs';
import _ from 'lodash';
@@ -1,10 +1,6 @@
import { queryClusterList } from '@/pages/cluster-management/apis';
import {
BaseSelect,
CardWrapper,
GaugeChart,
PageTools
} from '@gpustack/core-ui';
import { BaseSelect, CardWrapper, PageTools } from '@gpustack/core-ui';
import { GaugeChart } from '@gpustack/core-ui/charts';
import { useIntl } from '@umijs/max';
import { Col, Row } from 'antd';
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 { usageChartHeight, usageRankMaxItems } from '../../config';
import type useTopTokenUsageByUser from '../../hooks/use-top-token-usage-by-user';
@@ -1,6 +1,6 @@
import useCoolColors from '@/hooks/use-cool-colors';
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 { useIntl } from '@umijs/max';
import type { GlobalToken } from 'antd';
@@ -57,7 +57,7 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
.fetchData({
...commonParams,
metric: 'total_tokens',
group_by: ['date', 'route'],
group_by: ['route'],
page: 1,
perPage: 10,
sort_by: '-total_tokens'
@@ -67,7 +67,7 @@ const UsageByModel: React.FC<UsageByModelProps> = ({
.fetchData({
...commonParams,
metric: 'api_requests',
group_by: ['date', 'route'],
group_by: ['route'],
page: 1,
perPage: 10,
sort_by: '-api_requests'
@@ -1,5 +1,6 @@
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 dayjs from 'dayjs';
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';
interface TopUserProps {
@@ -77,7 +77,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
}, [resourceAtom, loadingStatus]);
const contentInfo = useMemo(() => {
if (!resourceCount.cluster_count) {
if (!resourceCount?.cluster_count) {
return {
title: intl.formatMessage({ id: 'noresult.cluster.title' }),
subTitle: intl.formatMessage({ id: 'noresult.resources.cluster' }),
@@ -98,7 +98,7 @@ export default function useAddResource(options?: { onCreated?: () => void }) {
const handleCreate = () => {
setHideModalTemporarily(true);
onCreated?.();
if (!resourceCount.cluster_count) {
if (!resourceCount?.cluster_count) {
setClusterSession({
firstAddWorker: false,
firstAddCluster: true
@@ -18,7 +18,7 @@ export default function useTopTokenUsageByUser(
.fetchData({
...commonParams,
metric: 'total_tokens',
group_by: ['date', 'user'],
group_by: ['user'],
page: 1,
perPage: 10,
sort_by: '-total_tokens'
@@ -7,6 +7,7 @@ import metaxLogo from '@/assets/logo/metax.png';
import mooreLogo from '@/assets/logo/moore-logo.png';
import nvidiaLogo from '@/assets/logo/nvidia.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 theadLogoEN from '@/assets/logo/t-head-en.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 vllmLogo from '@/assets/logo/vllm.png';
import PluginExtraFields from '@/components/plugin-extra-fields';
import useUserSettings from '@/hooks/use-user-settings';
import OwnerTag from '@/pages/gpu-service/components/owner-tag';
import {
GPUsConfigs,
@@ -33,7 +35,8 @@ import styled from 'styled-components';
import { manufactureColorMap, templateActions } from '../config';
import { ListItem } from '../config/types';
const imageLogoMap = {
// Light theme logos
const imageLogoLightMap = {
vllm: vllmLogo,
sglang: sgLangLogo,
jupyter: jupyterLogo,
@@ -42,25 +45,31 @@ const imageLogoMap = {
ubuntu: ubuntuLogo
} as const;
// Dark theme logos: inherit light, override only the ones that need a variant
const imageLogoDarkMap: typeof imageLogoLightMap = {
...imageLogoLightMap,
pytorch: pytorchLightLogo
};
const matchImageLogo = (
image: string | undefined
image: string | undefined,
isDark: boolean
): { logo: string; type: string } | null => {
if (!image) return null;
const logoMap = imageLogoLightMap;
const lower = image.toLowerCase();
let matched: keyof typeof imageLogoMap | null = null;
let matched: keyof typeof logoMap | null = null;
let earliest = Infinity;
(Object.keys(imageLogoMap) as Array<keyof typeof imageLogoMap>).forEach(
(key) => {
const idx = lower.indexOf(key);
if (idx !== -1 && idx < earliest) {
earliest = idx;
matched = key;
}
(Object.keys(logoMap) as Array<keyof typeof logoMap>).forEach((key) => {
const idx = lower.indexOf(key);
if (idx !== -1 && idx < earliest) {
earliest = idx;
matched = key;
}
);
});
return matched
? {
logo: imageLogoMap[matched],
logo: logoMap[matched],
type: matched
}
: null;
@@ -135,6 +144,7 @@ interface TemplateCardProps {
const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
const intl = useIntl();
const { isDarkTheme } = useUserSettings();
const manufacturerLabelMap: Record<string, string> = useMemo(() => {
return Object.values(GPUsConfigs).reduce(
@@ -158,7 +168,7 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
};
const renderLogo = () => {
const imageLogo = matchImageLogo(data.spec?.image);
const imageLogo = matchImageLogo(data.spec?.image, isDarkTheme);
if (imageLogo?.logo) {
return (
<LogoImg
+26 -29
View File
@@ -1,6 +1,7 @@
import { convertFileSize } from '@/utils';
import { AutoTooltip } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex } from 'antd';
import React from 'react';
import styled from 'styled-components';
import '../style/gpu-card.less';
@@ -19,12 +20,17 @@ const Header = styled.div`
width: 100%;
`;
const ItemInfo = styled.div`
display: flex;
flex-wrap: wrap;
white-space: break-spaces;
font-size: 13px;
`;
const Metric: React.FC<{ label: React.ReactNode; value: React.ReactNode }> = ({
label,
value
}) => (
<span style={{ lineHeight: 1.2 }}>
{label}:{' '}
<span className="font-500" style={{ color: 'var(--ant-color-text)' }}>
{value}
</span>
</span>
);
const Description = styled.div`
display: flex;
@@ -64,29 +70,20 @@ const GPUCard: React.FC<{
}
description={
info || (
<>
<ItemInfo>
{intl.formatMessage({ id: 'resources.table.vram' })}(
{intl.formatMessage({ id: 'resources.table.used' })}/
{intl.formatMessage({ id: 'resources.table.total' })}):{' '}
<span>
{convertFileSize(
data?.memory?.used || data?.memory?.allocated || 0
)}{' '}
/ {convertFileSize(data?.memory?.total || 0)}
</span>
</ItemInfo>
{/* <ItemInfo>
<span>
{intl.formatMessage({ id: 'resources.table.gpuutilization' })}
:{' '}
</span>
{data?.memory?.used
? _.round(data?.memory?.utilization_rate || 0, 2)
: _.round(data.memory?.allocated / data.memory?.total, 2) * 100}
%
</ItemInfo> */}
</>
<Flex wrap gap={8} style={{ fontSize: 13 }}>
<Metric
label={intl.formatMessage({ id: 'resources.table.total' })}
value={convertFileSize(data?.memory?.total || 0)}
/>
<Metric
label={intl.formatMessage({ id: 'resources.table.used' })}
value={convertFileSize(data?.memory?.used || 0)}
/>
<Metric
label={intl.formatMessage({ id: 'resources.table.allocated' })}
value={convertFileSize(data?.memory?.allocated || 0)}
/>
</Flex>
)
}
></CardContainer>
+10 -5
View File
@@ -1,5 +1,4 @@
import {
IconFont,
StatusTag,
TagsWrapper,
TemplateCard,
@@ -20,6 +19,7 @@ import {
MyModelsStatusValueMap
} from '../config';
import { categoryToPathMap } from '../config/button-actions';
import { getModelLogo } from '../utils/model-logo';
const CardWrapper = styled.div`
&: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`
margin-bottom: 8px;
display: flex;
@@ -176,10 +184,7 @@ const ModelItem: React.FC<{
header={
<Header>
<span className="text gap-8">
<IconFont
type={sourceIconMap[model.source]}
style={{ fontSize: 24 }}
/>
<ModelLogo src={getModelLogo(model.name)} alt="" />
<span>{model.name}</span>
</span>
<StatusTag
+22 -23
View File
@@ -13,7 +13,7 @@ import {
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import styled from 'styled-components';
import { DeployFormKeyMap, sourceOptions } from '../config';
import { useFormContext } from '../config/form-context';
@@ -24,6 +24,7 @@ import CustomBackend from './custom-backend';
import LocalPathSource from './local-path-source';
import ModeField from './mode-field';
import OnlineSource from './online-source';
const ClusterOption = styled.span`
display: flex;
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
// deployment's owner from the chosen cluster, so this keeps them aligned.
const scopeOrgId = Form.useWatch('organization_id', form);
const prevScopeRef = useRef<number | null | undefined>(undefined);
const clusterOptions = useMemo(() => {
return clusterList
@@ -139,37 +139,36 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
});
}, [clusterList, scopeOrgId]);
// On a genuine org change (not the initial value — the modal's open
// handler seeds the first cluster), drop a now-out-of-scope cluster and
// re-pick within the new org so GPU/backend options refetch for it.
// Keep the cluster selection consistent with the available (scoped)
// options. The modal's open handler seeds a cluster, but that seed can be
// 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(() => {
if (prevScopeRef.current === undefined) {
prevScopeRef.current = scopeOrgId;
if (!clusterOptions?.length) {
return;
}
if (prevScopeRef.current === scopeOrgId) {
return;
}
prevScopeRef.current = scopeOrgId;
const current = form.getFieldValue('cluster_id');
const stillValid = clusterOptions?.some((c) => c.value === current);
if (stillValid) {
const stillValid = clusterOptions.some((c) => c.value === current);
if (current != null && stillValid) {
return;
}
const next =
clusterOptions?.find((c) => c.is_default)?.value ??
clusterOptions?.find((c) => c.state === ClusterStatusValueMap.Ready)
clusterOptions.find((c) => c.is_default)?.value ??
clusterOptions.find((c) => c.state === ClusterStatusValueMap.Ready)
?.value ??
clusterOptions?.[0]?.value ??
clusterOptions[0]?.value ??
null;
form.setFieldValue('cluster_id', next ?? null);
if (next != null) {
handleClusterChange?.(next as number);
if (next == null || next === current) {
return;
}
// The prevScopeRef guard above makes this a no-op unless scopeOrgId
// actually changed, so listing the other deps is safe (no extra runs).
}, [scopeOrgId, clusterOptions, form, handleClusterChange]);
form.setFieldValue('cluster_id', next);
handleClusterChange?.(next);
}, [clusterOptions, form, handleClusterChange]);
const clusterOptionRender = (option: any) => {
const { data } = option;
@@ -20,6 +20,7 @@ import {
import { useFormContext } from '../config/form-context';
import { FormData } from '../config/types';
import { backendOptionsMap } from '../constants/backend-parameters';
import '../style/gpu-selector.less';
const InputWrapper = styled.div`
padding: 8px 4px;
+9 -10
View File
@@ -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 { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
@@ -105,15 +108,11 @@ const LLModels: React.FC = () => {
};
}, [activeKey]);
usePageContentStyle({ padding: 0 });
return (
<PageContainerInner
leftContent={title}
styles={{
containerWrapper: {
padding: 0
}
}}
>
<>
<HeaderLeft>{title}</HeaderLeft>
<DeploymentsContext.Provider
value={{
generateFormValues,
@@ -138,7 +137,7 @@ const LLModels: React.FC = () => {
activeKey={activeKey}
></Tabs>
</DeploymentsContext.Provider>
</PageContainerInner>
</>
);
};
@@ -0,0 +1,5 @@
.cascader-popup-wrapper.gpu-selector {
.ant-cascader-menu-item-content {
padding-right: 0;
}
}
+94
View File
@@ -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];
};

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