Compare commits

..
21 Commits
Author SHA1 Message Date
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
113 changed files with 678 additions and 1264 deletions
+18
View File
@@ -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 }}"
}
+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. 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
+1 -2
View File
@@ -17,7 +17,7 @@
"@ant-design/pro-components": "3.1.0-0", "@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51", "@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1", "@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.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",
+5 -13
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1 specifier: ^7.1.1
version: 7.1.2 version: 7.1.2
'@gpustack/core-ui': '@gpustack/core-ui':
specifier: ^1.0.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

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', rowSelectedBg: 'transparent',
headerSortActiveBg: 'transparent', headerSortActiveBg: 'transparent',
headerSortHoverBg: 'transparent', headerSortHoverBg: 'transparent',
bodySortBg: 'transparent',
headerBg: 'none' headerBg: 'none'
}, },
Button: { Button: {
+1
View File
@@ -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'
}, },
-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 { 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}
+4
View File
@@ -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',
+5
View File
@@ -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',
+4
View File
@@ -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',
+5
View File
@@ -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': '一般ユーザー',
+4
View File
@@ -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': 'Введите пароль',
+5
View File
@@ -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': 'Пользователь',
+4
View File
@@ -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',
+5
View File
@@ -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ı',
+3
View File
@@ -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': '请输入密码',
+5
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
import useCoolColors from '@/hooks/use-cool-colors'; import useCoolColors from '@/hooks/use-cool-colors';
import { Chart } from '@gpustack/core-ui'; 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';
+107 -40
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
import useCoolColors from '@/hooks/use-cool-colors'; import useCoolColors from '@/hooks/use-cool-colors';
import { Chart } from '@gpustack/core-ui'; 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;
} }
} }
} }
-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 { 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'] = [
{ {
+6 -5
View File
@@ -2,7 +2,7 @@ import { BaseSelect, DeleteModal } from '@gpustack/core-ui';
import { useIntl, useNavigate, useSearchParams } from '@umijs/max'; import { useIntl, useNavigate, useSearchParams } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { 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> </>
); );
}; };
+47 -14
View File
@@ -1,15 +1,15 @@
import { clusterDetailAtom } from '@/atoms/clusters';
import GPUList from '@/pages/resources/components/gpus'; import GPUList from '@/pages/resources/components/gpus';
import WorkerList from '@/pages/resources/components/workers'; import WorkerList from '@/pages/resources/components/workers';
import { getGPUStackPlugin } from '@/plugins'; import { getGPUStackPlugin } from '@/plugins';
import { 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 {
@@ -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
+10 -5
View File
@@ -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
+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 { 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> </>
); );
}; };
+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];
};
+36 -2
View File
@@ -1,10 +1,10 @@
import LogoIcon from '@/assets/images/gpustack-logo.png'; import LogoIcon from '@/assets/images/gpustack-logo.png';
import { userAtom } from '@/atoms/user'; import { userAtom } from '@/atoms/user';
import { useIntl, useModel } from '@umijs/max'; import { history, useIntl, useModel } from '@umijs/max';
import { Button, Divider, Form, Spin, message } from 'antd'; import { Button, Divider, Form, Spin, message } from 'antd';
import { createStyles } from 'antd-style'; import { createStyles } from 'antd-style';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { useLocalAuth } from '../hooks/use-local-auth'; import { useLocalAuth } from '../hooks/use-local-auth';
@@ -148,6 +148,40 @@ const LoginForm = () => {
}); });
}; };
// SSO callbacks (CAS / OIDC / SAML) are full-page IdP redirects, so
// a JSON exception raised in the callback lands the browser on a
// raw error page rather than this form. The backend instead
// redirects to ``/login?error=<code>`` on the failure modes that
// deserve user-facing copy — read the code on mount, route it
// through the existing toast, and clear the param from the URL so
// a refresh doesn't re-fire the toast.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const errorCode = params.get('error');
if (!errorCode) return;
// Codes recognised by the backend's SSO callback wrapper. Map to
// an i18n key per code; an unknown code is silently ignored so a
// future server release adding a code doesn't render a bare key.
const messageIdByCode: Record<string, string> = {
source_conflict: 'common.login.error.source_conflict',
auth_failed: 'common.login.error.auth_failed'
};
const messageId = messageIdByCode[errorCode];
if (messageId) {
handleOnError(new Error(intl.formatMessage({ id: messageId })));
}
params.delete('error');
const newQuery = params.toString();
// Route through ``@umijs/max``'s ``history`` rather than
// ``window.history.replaceState`` so the router's internal
// location stays in sync with the address bar — any other code
// that reads it (route guards, ``useLocation``, …) sees the
// cleaned URL on the same render.
history.replace(
window.location.pathname + (newQuery ? `?${newQuery}` : '')
);
}, []);
// local user authentication // local user authentication
const { handleLogin, submitLoading } = useLocalAuth({ const { handleLogin, submitLoading } = useLocalAuth({
fetchUserInfo, fetchUserInfo,
@@ -58,7 +58,7 @@ const useProviderColumns = (
{ {
title: intl.formatMessage({ id: 'providers.table.providerName' }), title: intl.formatMessage({ id: 'providers.table.providerName' }),
dataIndex: ['config', 'type'], dataIndex: ['config', 'type'],
sorter: tableSorter(2), sorter: false,
span: 4, span: 4,
minWidth: 160, minWidth: 160,
render: (value: string) => ( render: (value: string) => (
+1
View File
@@ -170,6 +170,7 @@ const MaasProvider: React.FC = () => {
rowSelection={rowSelection} rowSelection={rowSelection}
columns={columns} columns={columns}
scroll={{ x: 900 }} scroll={{ x: 900 }}
onChange={handleTableChange}
pagination={{ pagination={{
size: 'middle', size: 'middle',
showSizeChanger: true, showSizeChanger: true,
+25 -32
View File
@@ -6,11 +6,14 @@ import { MessageOutlined, OneToOneOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { Segmented, Tabs, TabsProps } from 'antd'; import { Segmented, Tabs, TabsProps } from 'antd';
import classNames from 'classnames';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { PageContainerInner } from '../../_components/page-box'; import {
HeaderLeft,
HeaderRight,
usePageContentStyle
} from '../../_components/page-box';
import { queryModelsList } from '../apis'; import { queryModelsList } from '../apis';
import MultipleChat from '../components/multiple-chat'; import MultipleChat from '../components/multiple-chat';
import ViewCodeButtons from '../components/view-code-buttons'; import ViewCodeButtons from '../components/view-code-buttons';
@@ -148,53 +151,43 @@ const Playground: React.FC = () => {
} }
); );
usePageContentStyle({ padding: 0 });
return ( return (
<PageContainerInner <>
className={classNames('playground-container', { <HeaderLeft>
compare: activeKey === 'compare',
chat: activeKey !== 'compare'
})}
styles={{
containerWrapper: {
padding: 0
}
}}
leftContent={
<div className="flex items-center"> <div className="flex items-center">
<span className="font-600 flex-center"> <span className="font-600 flex-center">
{intl.formatMessage({ id: 'menu.playground.chat' })} {intl.formatMessage({ id: 'menu.playground.chat' })}
</span> </span>
{ <Segmented
<Segmented shape="round"
shape="round" style={{
style={{ backgroundColor: 'var(--ant-color-fill-secondary)',
backgroundColor: 'var(--ant-color-fill-secondary)', fontSize: 13
fontSize: 13 }}
}} size="middle"
size="middle" className="m-l-24 font-400"
className="m-l-24 font-400" options={optionsList}
options={optionsList} value={activeKey}
value={activeKey} onChange={(key) => setActiveKey(key)}
onChange={(key) => setActiveKey(key)} ></Segmented>
></Segmented>
}
</div> </div>
} </HeaderLeft>
rightContent={ <HeaderRight>
<ViewCodeButtons <ViewCodeButtons
handleViewCode={handleViewCode} handleViewCode={handleViewCode}
handleToggleCollapse={handleToggleCollapse} handleToggleCollapse={handleToggleCollapse}
activeKey={activeKey} activeKey={activeKey}
key="view-code-buttons" key="view-code-buttons"
/> />
} </HeaderRight>
>
<div className="play-ground"> <div className="play-ground">
<div className="chat"> <div className="chat">
<Tabs items={items} activeKey={activeKey}></Tabs> <Tabs items={items} activeKey={activeKey}></Tabs>
</div> </div>
</div> </div>
</PageContainerInner> </>
); );
}; };
@@ -1,5 +1,5 @@
import FullMarkdown from '@/components/full-markdown';
import { SimpleAudio } from '@gpustack/core-ui'; import { SimpleAudio } from '@gpustack/core-ui';
import { FullMarkdown } from '@gpustack/core-ui/markdown';
import { Input } from 'antd'; import { Input } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import _ from 'lodash'; import _ from 'lodash';

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