Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
976df98b71 | ||
|
|
6dc2676c66 | ||
|
|
81bcd6a5ac | ||
|
|
286f49897c | ||
|
|
3f811735ff | ||
|
|
4b790bcfe4 | ||
|
|
4da7a19520 | ||
|
|
2032bf097e | ||
|
|
29b965ebbd | ||
|
|
aca6490da1 | ||
|
|
04511a63a3 | ||
|
|
28da5d52e8 | ||
|
|
730cbc9b64 | ||
|
|
2a69ba65cb | ||
|
|
aefb8eadce | ||
|
|
49c80b2ccb | ||
|
|
654eca7544 | ||
|
|
5ee62f267c | ||
|
|
ba0b2bb2c3 | ||
|
|
c3bdbc200e | ||
|
|
63b06e595e | ||
|
|
27210da13a | ||
|
|
831a760246 | ||
|
|
ae06ad7aae | ||
|
|
b3b819a080 | ||
|
|
aa47c626ba | ||
|
|
79e4a60d52 | ||
|
|
d9a467f9f9 | ||
|
|
c6b89f688e | ||
|
|
2164a8198b | ||
|
|
0f0e92379b | ||
|
|
d8cb5261b9 | ||
|
|
e30dd10fe4 | ||
|
|
67e2d914f4 | ||
|
|
938aef6c26 | ||
|
|
eb029a3e2a | ||
|
|
2dd287bbc1 | ||
|
|
3c00317aeb | ||
|
|
06a2777632 | ||
|
|
8f0691395b | ||
|
|
8eb871d0f5 | ||
|
|
6e321d2d99 | ||
|
|
97179365bb | ||
|
|
046d466e16 | ||
|
|
77a2abad32 | ||
|
|
efb63335e7 | ||
|
|
d0b6498bda | ||
|
|
35ed397c2a | ||
|
|
cb380d676e | ||
|
|
03c47530bd | ||
|
|
ab92641541 | ||
|
|
1f861c5844 | ||
|
|
f92499cbe5 | ||
|
|
6a9cba8020 | ||
|
|
4f4b527014 | ||
|
|
0d5812ba88 | ||
|
|
650091b57a | ||
|
|
5bdfb36721 | ||
|
|
0436872093 | ||
|
|
3d88b2825b | ||
|
|
dc54c21d70 | ||
|
|
794ac724ca | ||
|
|
d91fbec9d7 | ||
|
|
555aab714e | ||
|
|
eb037a3613 | ||
|
|
b1efa415e8 | ||
|
|
e79d49f2a8 | ||
|
|
7d9f68da2d | ||
|
|
c691df25c0 | ||
|
|
cd4b1b8629 | ||
|
|
70229a9757 | ||
|
|
644aba3fc2 | ||
|
|
c025257fea | ||
|
|
fdafdfc3d1 | ||
|
|
f18c12662b | ||
|
|
71840cfc8f | ||
|
|
e75beb7324 | ||
|
|
90cc6ec753 | ||
|
|
bd4954f161 | ||
|
|
32fab0fa62 | ||
|
|
d12e92a916 | ||
|
|
dc41b61d69 | ||
|
|
3a1565690d | ||
|
|
1bb9fd73db | ||
|
|
39024b1d46 | ||
|
|
4ecaa0a326 | ||
|
|
e1af27e11c | ||
|
|
f4ebd8d7d1 | ||
|
|
bd58a911d5 | ||
|
|
5321973ebe | ||
|
|
0a1f875590 | ||
|
|
ea2c9870a7 | ||
|
|
23f0e4398a | ||
|
|
b342174fc2 | ||
|
|
d07d3b2c99 | ||
|
|
9d0a5d01b0 | ||
|
|
1e97d29fca | ||
|
|
350f398cde | ||
|
|
8a15172316 | ||
|
|
185e3475e7 | ||
|
|
64720ccbfa | ||
|
|
db17192e55 | ||
|
|
a1dd1ec861 | ||
|
|
a516e6ce72 | ||
|
|
2e375c6603 | ||
|
|
7d94c77c15 | ||
|
|
457d2f2f72 | ||
|
|
5e7d83e5dd | ||
|
|
b1cffe047c | ||
|
|
6509f2a4ff | ||
|
|
f719c11606 | ||
|
|
b8d7873b77 | ||
|
|
a9248a98a5 | ||
|
|
8f85e9a082 | ||
|
|
1a6d1654b2 | ||
|
|
abe705c034 | ||
|
|
8b34f68824 | ||
|
|
e37828a2cd | ||
|
|
4709661f93 | ||
|
|
1d7543f19c | ||
|
|
1cf146ca32 | ||
|
|
b05c510776 | ||
|
|
e79d5f8962 | ||
|
|
5503fdc21e | ||
|
|
36a1038d12 |
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: create-crud-page
|
||||
description: Scaffold a CRUD list/table page module in the gpustack-ui monorepo. Use when creating a new page module, building a list/table page, adding a create/edit drawer, or setting up the components/config/forms/hooks/services structure for a feature.
|
||||
---
|
||||
|
||||
# Create a CRUD Table Page
|
||||
|
||||
## Inputs (do this first)
|
||||
|
||||
- The argument passed to this skill is the **module name** (e.g. `/create-crud-page api-keys` → module `api-keys`). If no name was given, ask for it.
|
||||
- **Always ask the user for the API documentation before generating any code**, even if a module name was provided:
|
||||
|
||||
> Where is the API documentation for this module? (OpenAPI/Swagger URL, schema file path, or an interface description)
|
||||
|
||||
- Wait for the answer, then read/fetch it. Derive `config/types.ts` (`FormData`, `ListItem`), the `services` request hooks, and form fields from that schema. Do not guess field names or endpoints — if the doc is missing details, ask.
|
||||
|
||||
- **Also ask which form layout to scaffold:**
|
||||
|
||||
> Should the form use tabs? (1) a plain form without tabs, or (2) a tabbed form
|
||||
|
||||
Choose the form structure in section 3 accordingly. Default to **no tabs** unless the user picks tabs or the schema clearly has many grouped sections.
|
||||
|
||||
---
|
||||
|
||||
Before anything: **reuse common `components`, `hooks`, and `utils` from `@gpustack/core-ui` whenever possible.**
|
||||
|
||||
Reference implementation for sections below: `src/pages/model-routes`.
|
||||
|
||||
## Module structure
|
||||
|
||||
Create the module under `src/pages/{module}`:
|
||||
|
||||
```text
|
||||
{module}
|
||||
├── components
|
||||
├── config
|
||||
├── forms
|
||||
├── hooks
|
||||
├── index.tsx
|
||||
└── services
|
||||
```
|
||||
|
||||
## 1. components
|
||||
|
||||
Module-specific components.
|
||||
|
||||
- The create/edit form component is named `add-xxx-modal.tsx` (repo convention — keep the `-modal` suffix even though it is built with `FormDrawer`).
|
||||
- Use `FormDrawer` from `@gpustack/core-ui`.
|
||||
- If a table cell's render logic/structure is complex, extract it into `xxx-cell.tsx`.
|
||||
|
||||
## 2. config
|
||||
|
||||
```text
|
||||
config
|
||||
├── index.ts # static configs & constants
|
||||
└── types.ts # TypeScript types
|
||||
```
|
||||
|
||||
Naming: form types → `FormData`; table list item types → `ListItem`.
|
||||
|
||||
## 3. forms
|
||||
|
||||
Main form component goes in `forms/index.tsx`.
|
||||
|
||||
- **Complex interactions** (Form.Item split across components): create a dedicated Form Context and wrap with `FormContext.Provider`.
|
||||
- **Tab-based forms**: use `ScrollSpyTabs` from `@gpustack/core-ui`, wrapping the `Form` or `FormContext.Provider`. Do not use tabs unless necessary.
|
||||
- **Required-field validation**: use `getRuleMessage` for standard `input`/`select`.
|
||||
- For cascading selectors and async race protection, follow the **form-patterns** skill.
|
||||
|
||||
## 4. hooks
|
||||
|
||||
- Table columns → `use-xxx-columns.tsx`.
|
||||
- Open/close hooks for `add-xxx-modal.tsx` → `use-create-xxx.ts`.
|
||||
|
||||
## 5. index.tsx (list page entry)
|
||||
|
||||
- **Data fetching**: `useTableFetch` from `@gpustack/core-ui`.
|
||||
- **Data display**:
|
||||
- Standard table → Ant Design `Table`. Ref: `src/pages/users/index.tsx`.
|
||||
- Expandable/collapsible rows → `Table` from `@gpustack/core-ui`. Ref: `src/pages/model-routes/index.tsx`.
|
||||
- Card-style lists → use `InfiniteScrollerProvider`. Ref: `src/pages/backends/index.tsx`.
|
||||
|
||||
## 6. services
|
||||
|
||||
`request` is injected via a provider — do **not** create a centralized `apis` directory like in `gpustack-ui`. Define request hooks directly in `services`.
|
||||
|
||||
- Use `useRequest` from `@gpustack/core-ui`, or `useQueryData` (same underlying method).
|
||||
- Ref: `src/pages/gpu-service/storage-types/services/use-create-storage-type.ts`.
|
||||
|
||||
## 7. Empty data
|
||||
|
||||
- Page table lists → `NoResult`.
|
||||
- Simple (non-page) tables → `Empty` with `image={Empty.PRESENTED_IMAGE_SIMPLE}`.
|
||||
|
||||
## Common UI conventions
|
||||
|
||||
- **Drawer/Modal open/close**: use `useBodyScroll` from `@gpustack/core-ui`. Ref: `src/pages/model-routes/hooks/use-create-route.ts`.
|
||||
- **Status display** (success/failed/processing/warning): use `StatusTag`, never `Tag` from `antd` directly. See **Status display** below. Ref: `src/pages/llmodels/components/table-list.tsx`.
|
||||
- **Permission-gated visibility**: use `Access` / `useAccess`. Ref: `src/pages/access/index.tsx`.
|
||||
- **Styles**: avoid `styled-components` for complex/large styling. Prefer `createStyles` for component-scoped dynamic styles, CSS Modules (`xxx.module.less`) for static structured styles.
|
||||
|
||||
## Status display
|
||||
|
||||
Use `StatusTag` from `@gpustack/core-ui` for status display. Do not use `Tag` from `antd` directly.
|
||||
|
||||
1. Define the mapping from business status values to UI status values in the module's `config/index.ts`:
|
||||
|
||||
```ts
|
||||
import { StatusMaps } from '@/config';
|
||||
import { StatusType } from '@/config/types';
|
||||
|
||||
export const XxxStatusValueMap = {
|
||||
Running: 'running',
|
||||
Pending: 'pending',
|
||||
Failed: 'failed'
|
||||
};
|
||||
|
||||
export const XxxStatusLabelMap: Record<string, string> = {
|
||||
[XxxStatusValueMap.Running]: 'Running',
|
||||
[XxxStatusValueMap.Pending]: 'Pending',
|
||||
[XxxStatusValueMap.Failed]: 'Failed'
|
||||
};
|
||||
|
||||
export const status: Record<string, StatusType> = {
|
||||
[XxxStatusValueMap.Running]: StatusMaps.success,
|
||||
[XxxStatusValueMap.Pending]: StatusMaps.transitioning,
|
||||
[XxxStatusValueMap.Failed]: StatusMaps.error
|
||||
};
|
||||
```
|
||||
|
||||
2. In table columns, pass only the UI status value and display text required by `StatusTag`:
|
||||
|
||||
```tsx
|
||||
<StatusTag
|
||||
statusValue={{
|
||||
status: status[value],
|
||||
text: XxxStatusLabelMap[value] || value,
|
||||
message: record.state_message
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
`statusValue.status` must be a value mapped from `StatusMaps` (`success`, `transitioning`, `warning`, `error`, `inactive`). Do not pass business status values such as `running` or `pending` directly.
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: form-patterns
|
||||
description: Patterns for forms with cascading/dependent selections in the gpustack-ui monorepo. Use when building a form where picking one field derives another (pick A → auto-pick B → write form), handling async option loading on modal open, or protecting against stale async results.
|
||||
---
|
||||
|
||||
# Form Patterns
|
||||
|
||||
Theme: keep cascading selections (pick A → derive B → write form) on a single, predictable path.
|
||||
|
||||
## Accessing the form: `form` vs `form.current`
|
||||
|
||||
This is **not** absolute — it depends on the call site:
|
||||
|
||||
- **Inside the form component** (`forms/index.tsx`), or anywhere holding a `Form.useForm()` instance → call it directly: `form.setFieldsValue(...)`.
|
||||
- **In the outer Drawer/Modal wrapper** that opens the form and holds it via `ref={form}` (`const form = useRef(null)`), driven by an `open` prop → go through the ref: `form.current?.setFieldsValue(...)`.
|
||||
|
||||
The reference template below is written for the **Drawer-wrapper scenario** (it reacts to `open` and owns the shared `selection` state), so it uses `form.current?` throughout. If you lift this logic into the form body with a `useForm()` instance, drop the `.current`.
|
||||
|
||||
## 1. No fallback for derived selection
|
||||
|
||||
When "pick A then auto-pick B", match by rule and return `undefined` if no match — let the form field stay empty. Do **not** silently fall back to `list[0]`; a fallback hides data issues and fakes a valid selection.
|
||||
|
||||
```ts
|
||||
const findB = (key, list) =>
|
||||
key ? list.find((x) => x.key === key) : undefined;
|
||||
```
|
||||
|
||||
For form fields, clear with `undefined`, not `''`. In Ant Design `undefined` restores the placeholder; `''` is treated as a real value.
|
||||
|
||||
## 2. Async race protection
|
||||
|
||||
For fetches triggered by a lifecycle entry (e.g. modal open), tag each invocation with a session ref. Discard stale results if the session rotated (modal closed and re-opened) before the response arrives.
|
||||
|
||||
```ts
|
||||
const sessionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
sessionRef.current += 1;
|
||||
return;
|
||||
}
|
||||
const session = ++sessionRef.current;
|
||||
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
applySelection(as.items[0], findB(as.items[0].key, bs.items));
|
||||
});
|
||||
}, [open]);
|
||||
```
|
||||
|
||||
## 3. Reference template
|
||||
|
||||
Two cascading selectors backed by a single shared state, with a single atomic write (state + form together):
|
||||
|
||||
```ts
|
||||
type Selection = { a?: string; b?: number };
|
||||
|
||||
const [selection, setSelection] = useState<Selection>({});
|
||||
const sessionRef = useRef(0);
|
||||
const form = useRef<any>(null); // wrapper holds the form via <Form ref={form} /> — see "Accessing the form" above
|
||||
|
||||
const findB = (key, list) =>
|
||||
key ? list.find((x) => x.key === key) : undefined;
|
||||
|
||||
// Single atomic write: state + form together.
|
||||
const applySelection = (a, b) => {
|
||||
setSelection({ a: a.name, b: b?.id });
|
||||
form.current?.setFieldsValue({
|
||||
field: b?.field,
|
||||
spec: { ...currentSpec, ...b?.spec }
|
||||
});
|
||||
};
|
||||
|
||||
// Trigger 1: modal opened
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
sessionRef.current++;
|
||||
setSelection({});
|
||||
return;
|
||||
}
|
||||
const session = ++sessionRef.current;
|
||||
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const first = as.items[0];
|
||||
applySelection(first, findB(first.key, bs.items));
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
// Trigger 2: user picks A
|
||||
const handleAChange = (a) => {
|
||||
applySelection(a, findB(a.key, listB));
|
||||
};
|
||||
|
||||
// Trigger 3: user picks B
|
||||
const handleBChange = (b) => {
|
||||
setSelection((prev) => ({ ...prev, b: b.id }));
|
||||
form.current?.setFieldsValue({ ...b.fields });
|
||||
};
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- Module/file structure for forms lives in the **create-crud-page** skill (section 3).
|
||||
- Required-field validation: use `getRuleMessage`.
|
||||
@@ -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 }}"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
.swc
|
||||
.DS_Store
|
||||
.idea
|
||||
.claude
|
||||
.claude/settings.local.json
|
||||
/dist.zip
|
||||
.cache
|
||||
@@ -1,265 +0,0 @@
|
||||
# React State and Request Patterns
|
||||
|
||||
These guidelines define preferred patterns for request handling, state updates, and side-effect management in React applications.
|
||||
|
||||
The primary goal is to keep data flow explicit, predictable, maintainable, and performant while avoiding unnecessary rerenders and effect-driven logic.
|
||||
|
||||
---
|
||||
|
||||
## 1. Avoid Effect-Driven Requests
|
||||
|
||||
Do not use request functions themselves as dependencies in `useEffect`.
|
||||
|
||||
Avoid patterns like:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
```
|
||||
|
||||
Requests should be triggered explicitly by user actions or lifecycle entry points.
|
||||
|
||||
---
|
||||
|
||||
## 2. Form Requests Should Be Action-Driven
|
||||
|
||||
For form-related requests (such as loading `Select` options):
|
||||
|
||||
- Fetch data when the form is opened for the first time.
|
||||
- If later requests depend on user interactions, trigger them directly inside the interaction handler.
|
||||
- Do not rely on `useEffect` dependency changes to trigger requests.
|
||||
|
||||
Recommended:
|
||||
|
||||
```ts
|
||||
const handleOnChange = (value) => {
|
||||
fetchData(value);
|
||||
};
|
||||
```
|
||||
|
||||
Avoid:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
fetchData(value);
|
||||
}, [value]);
|
||||
```
|
||||
|
||||
The action itself should control the request.
|
||||
|
||||
---
|
||||
|
||||
## 3. Update Related States Together
|
||||
|
||||
If a single action updates multiple related states:
|
||||
|
||||
- Do not synchronize them through `useEffect`
|
||||
- Do not derive them indirectly through `useMemo`
|
||||
|
||||
Instead, update all related states directly inside the action handler.
|
||||
|
||||
Recommended:
|
||||
|
||||
```ts
|
||||
const handleOnChange = (value) => {
|
||||
setState1(...);
|
||||
setState2(...);
|
||||
buildState(...);
|
||||
};
|
||||
```
|
||||
|
||||
Avoid implicit state synchronization chains.
|
||||
|
||||
---
|
||||
|
||||
## 4. Group Strongly Related State
|
||||
|
||||
If multiple states are always updated together:
|
||||
|
||||
- Do not split them into multiple `useState` calls.
|
||||
- Prefer a single state object.
|
||||
|
||||
Recommended:
|
||||
|
||||
```ts
|
||||
const [state, setState] = useState({
|
||||
state1: ...,
|
||||
state2: ...,
|
||||
state3: ...,
|
||||
});
|
||||
```
|
||||
|
||||
This reduces unnecessary rerenders and keeps state transitions predictable.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prefer Explicit State Flow
|
||||
|
||||
Avoid chaining business logic through multiple `useEffect` hooks.
|
||||
|
||||
Keep:
|
||||
|
||||
- request execution
|
||||
- state updates
|
||||
- derived calculations
|
||||
|
||||
close to the triggering action whenever possible.
|
||||
|
||||
Prefer:
|
||||
|
||||
```ts
|
||||
const handleAction = () => {
|
||||
fetchData();
|
||||
setTableData(...);
|
||||
setSelectedRow(...);
|
||||
};
|
||||
```
|
||||
|
||||
Over:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
buildTable();
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
updateSelection();
|
||||
}, [tableData]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Avoid Premature Memoization
|
||||
|
||||
Do not use `useMemo` or `useCallback` unless there is a confirmed rendering or computation bottleneck.
|
||||
|
||||
Overusing memoization:
|
||||
|
||||
- increases complexity
|
||||
- makes state flow harder to understand
|
||||
- may introduce stale dependency issues
|
||||
|
||||
Prefer simple and explicit logic first.
|
||||
|
||||
Optimize only when necessary.
|
||||
|
||||
---
|
||||
|
||||
## 7. Keep Request Logic Predictable
|
||||
|
||||
A user interaction should clearly show:
|
||||
|
||||
- what request is triggered
|
||||
- which states are updated
|
||||
- how the UI changes
|
||||
|
||||
Avoid indirect update chains caused by dependency-driven effects.
|
||||
|
||||
The code should make the request and update flow easy to trace.
|
||||
|
||||
---
|
||||
|
||||
## 8. Prefer Action-Driven Architecture
|
||||
|
||||
Prefer:
|
||||
|
||||
- action-driven updates
|
||||
- explicit handlers
|
||||
- localized state transitions
|
||||
|
||||
Over:
|
||||
|
||||
- effect-driven synchronization
|
||||
- cross-hook implicit updates
|
||||
- reactive chains between states
|
||||
|
||||
The triggering action should remain the primary source of truth for UI updates.
|
||||
|
||||
---
|
||||
|
||||
# Form
|
||||
|
||||
Form-specific patterns that build on the rules above. The theme: keep cascading selections (pick A → derive B → write form) on a single, predictable path.
|
||||
|
||||
## 1. No Fallback for Derived Selection
|
||||
|
||||
When "pick A then auto-pick B", match by rule and return `undefined` if no match — let the corresponding form field stay empty.
|
||||
|
||||
Do not silently fall back to `list[0]` or another default. A fallback hides data issues and tricks the user into thinking they have a valid selection.
|
||||
|
||||
```ts
|
||||
const findB = (key, list) =>
|
||||
key ? list.find((x) => x.key === key) : undefined;
|
||||
```
|
||||
|
||||
For form fields, prefer clearing with `undefined` over `''`. With Ant Design, `undefined` restores the placeholder; `''` is treated as a real value.
|
||||
|
||||
## 2. Async Race Protection
|
||||
|
||||
For fetches triggered by a lifecycle entry (e.g., modal open), tag each invocation with a session ref. Discard stale results if the session has rotated (the modal was closed and re-opened) by the time the response arrives.
|
||||
|
||||
```ts
|
||||
const sessionRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
sessionRef.current += 1;
|
||||
return;
|
||||
}
|
||||
const session = ++sessionRef.current;
|
||||
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
applySelection(as.items[0], findB(as.items[0].key, bs.items));
|
||||
});
|
||||
}, [open]);
|
||||
```
|
||||
|
||||
## 3. Reference Template
|
||||
|
||||
A typical form with two cascading selectors backed by a single shared state:
|
||||
|
||||
```ts
|
||||
type Selection = { a?: string; b?: number };
|
||||
|
||||
const [selection, setSelection] = useState<Selection>({});
|
||||
const sessionRef = useRef(0);
|
||||
|
||||
const findB = (key, list) =>
|
||||
key ? list.find((x) => x.key === key) : undefined;
|
||||
|
||||
// Single atomic write: state + form together.
|
||||
const applySelection = (a, b) => {
|
||||
setSelection({ a: a.name, b: b?.id });
|
||||
form.current?.setFieldsValue({
|
||||
field: b?.field,
|
||||
spec: { ...currentSpec, ...b?.spec }
|
||||
});
|
||||
};
|
||||
|
||||
// Trigger 1: modal opened
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
sessionRef.current++;
|
||||
setSelection({});
|
||||
return;
|
||||
}
|
||||
const session = ++sessionRef.current;
|
||||
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const first = as.items[0];
|
||||
applySelection(first, findB(first.key, bs.items));
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
// Trigger 2: user picks A
|
||||
const handleAChange = (a) => {
|
||||
applySelection(a, findB(a.key, listB));
|
||||
};
|
||||
|
||||
// Trigger 3: user picks B
|
||||
const handleBChange = (b) => {
|
||||
setSelection((prev) => ({ ...prev, b: b.id }));
|
||||
form.current?.setFieldsValue({ ...b.fields });
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# Agent Instructions
|
||||
|
||||
This project keeps a single source of truth for agent/contributor conventions in [`CLAUDE.md`](./CLAUDE.md). **Read [`CLAUDE.md`](./CLAUDE.md) and follow it.**
|
||||
|
||||
@CLAUDE.md
|
||||
|
||||
## Downstream fork workflow
|
||||
|
||||
This repo is a **downstream fork** that customizes the product appearance on top of upstream `gpustack/gpustack-ui`. The mirror chain is:
|
||||
|
||||
```
|
||||
upstream https://github.com/gpustack/gpustack-ui.git
|
||||
| (fetch)
|
||||
origin ssh://git@192.168.0.23:11022/root/gpustack-ui.git (private registry, also https://git.digiman.live)
|
||||
```
|
||||
|
||||
### Goal
|
||||
|
||||
Track upstream releases. When upstream changes, we pull it in, then apply our own appearance/customization changes on a dedicated branch so we keep our look-and-feel on top of the latest upstream product.
|
||||
|
||||
### Branch naming
|
||||
|
||||
Customization work lives on `v<upstream-version>-lofyer` branches (e.g. `v2.2.0-lofyer`). Each time upstream ships a new version we want to follow, create a new `v<version>-lofyer` branch from the corresponding upstream tag/branch and re-apply (or rebase) our customizations onto it.
|
||||
|
||||
### Syncing from upstream
|
||||
|
||||
`scripts/sync-github` mirrors upstream into the private `origin` (full mirror, force push of all branches + tags). It auto-configures the `upstream` remote on first run.
|
||||
|
||||
```bash
|
||||
# preview only, no push
|
||||
DRY_RUN=1 ./scripts/sync-github
|
||||
|
||||
# real sync (force-pushes every upstream branch + tag to origin)
|
||||
./scripts/sync-github
|
||||
|
||||
# also delete origin branches that no longer exist upstream (true mirror, destructive)
|
||||
PRUNE_BRANCHES=1 ./scripts/sync-github
|
||||
```
|
||||
|
||||
Env knobs: `UPSTREAM_URL`, `ORIGIN_REMOTE`, `UPSTREAM_REMOTE`, `PRUNE_BRANCHES`, `DRY_RUN`. After syncing, branch a fresh `v<version>-lofyer` off the updated upstream ref and apply the appearance changes there.
|
||||
|
||||
### Re-applying brand customizations
|
||||
|
||||
`scripts/rebrand` swaps the standalone brand word `GPUStack` for our brand (`MesaStack`) across user-facing text. It is the first appearance change to re-apply on every new `v<version>-lofyer` branch.
|
||||
|
||||
```bash
|
||||
# preview hits, no writes
|
||||
DRY_RUN=1 ./scripts/rebrand
|
||||
|
||||
# apply (default GPUStack -> MesaStack)
|
||||
./scripts/rebrand
|
||||
|
||||
# custom brand words
|
||||
FROM=GPUStack TO=AcmeStack ./scripts/rebrand
|
||||
```
|
||||
|
||||
It deliberately **does not** touch functional references — lowercase `gpustack` (npm pkg / URLs / paths / k8s namespace), ALL-CAPS `GPUSTACK_*` constants, JS identifiers like `getGPUStackPlugin`, and `X-*` HTTP headers — and carries a line-level skip list for backend-contract strings matched at runtime (see `SKIP_LINE_PATTERNS` in the script). Always review `git diff` afterwards. Logo images under `src/assets/images/` are NOT changed by the script — replace those PNGs separately when new brand assets are available.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Repo
|
||||
|
||||
This is the **open source UI** (`gpustack-ui`). Common `components`, `hooks`, and `utils` are published as `@gpustack/core-ui` and consumed throughout `src`.
|
||||
|
||||
**Always prioritize reusing common `components`, `hooks`, and `utils` from `@gpustack/core-ui`.**
|
||||
|
||||
Task-specific conventions live in skills: use **create-crud-page** when building a page module, **form-patterns** when building cascading/dependent forms.
|
||||
|
||||
# React State and Request Patterns
|
||||
|
||||
Keep data flow explicit, predictable, and performant. The triggering **action** is the source of truth for UI updates — not effect-driven synchronization.
|
||||
|
||||
## 1. Avoid effect-driven requests
|
||||
|
||||
Do not use request functions as `useEffect` dependencies. Trigger requests explicitly from user actions or lifecycle entry points.
|
||||
|
||||
```ts
|
||||
// Avoid
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
```
|
||||
|
||||
## 2. Form requests should be action-driven
|
||||
|
||||
- Fetch form data (e.g. `Select` options) when the form first opens.
|
||||
- If later requests depend on interactions, trigger them inside the interaction handler.
|
||||
- Do not rely on `useEffect` dependency changes.
|
||||
|
||||
```ts
|
||||
// Recommended
|
||||
const handleOnChange = (value) => {
|
||||
fetchData(value);
|
||||
};
|
||||
```
|
||||
|
||||
## 3. Update related states together
|
||||
|
||||
When one action updates multiple related states, update them all directly in the handler. Do not sync via `useEffect` or derive indirectly via `useMemo`.
|
||||
|
||||
```ts
|
||||
const handleOnChange = (value) => {
|
||||
setState1(...);
|
||||
setState2(...);
|
||||
buildState(...);
|
||||
};
|
||||
```
|
||||
|
||||
## 4. Group strongly related state
|
||||
|
||||
If multiple states always update together, use a single state object instead of multiple `useState` calls — fewer rerenders, more predictable transitions.
|
||||
|
||||
```ts
|
||||
const [state, setState] = useState({ state1: ..., state2: ..., state3: ... });
|
||||
```
|
||||
|
||||
## 5. Prefer explicit state flow
|
||||
|
||||
Keep request execution, state updates, and derived calculations close to the triggering action. Avoid chaining business logic through multiple `useEffect` hooks.
|
||||
|
||||
```ts
|
||||
// Prefer
|
||||
const handleAction = () => {
|
||||
fetchData();
|
||||
setTableData(...);
|
||||
setSelectedRow(...);
|
||||
};
|
||||
```
|
||||
|
||||
## 6. Avoid premature memoization
|
||||
|
||||
Do not use `useMemo` / `useCallback` unless there is a confirmed bottleneck. Overuse adds complexity, obscures state flow, and risks stale dependencies. Optimize only when necessary.
|
||||
|
||||
## 7. Keep request logic predictable
|
||||
|
||||
A user interaction should clearly show: what request fires, which states update, how the UI changes. Avoid indirect update chains from dependency-driven effects.
|
||||
|
||||
## 8. Prefer action-driven architecture
|
||||
|
||||
Prefer action-driven updates, explicit handlers, and localized state transitions over effect-driven synchronization, cross-hook implicit updates, and reactive chains between states.
|
||||
|
||||
# Styles
|
||||
|
||||
**Future direction (apply to all new code):** avoid `styled-components`. Prefer:
|
||||
|
||||
1. `createStyles` for component-scoped dynamic styles
|
||||
2. CSS Modules (`xxx.module.less`) for structured static styles
|
||||
|
||||
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:
|
||||
|
||||
- **Create/edit modal**: `add-{feature}-modal.tsx` (keep the `-modal` suffix even when built with `FormDrawer`).
|
||||
- **Table columns hook**: `use-{feature}-columns.tsx`.
|
||||
- **Open/close & request hooks**: `use-{verb}-{noun}.ts` (e.g. `use-create-user.ts`, `use-query-user-list.ts`).
|
||||
- **Complex table cell**: extract into `{feature}-cell.tsx`.
|
||||
|
||||
# Config & types
|
||||
|
||||
- `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
|
||||
|
||||
Always check `@gpustack/core-ui` first. Frequently reused:
|
||||
|
||||
- **Drawer/Modal open/close**: `useBodyScroll`.
|
||||
- **Form drawer / footer**: `FormDrawer`, `ModalFooter`.
|
||||
- **Delete confirmation**: `DeleteModal`.
|
||||
- **Search + bulk actions bar**: `FilterBar`.
|
||||
- **Form fields**: `BaseSelect`, `Input` (labeled).
|
||||
- **Text overflow**: `AutoTooltip`.
|
||||
- **Icons**: `IconFont`.
|
||||
- **Tags & status** (4 variants): see the section below.
|
||||
- **Permission-gated visibility**: `Access` / `useAccess`.
|
||||
- **Request hooks**: `useRequest` / `useQueryData` / `useQueryDataList`.
|
||||
- **Table data fetching**: `useTableFetch`.
|
||||
- **Submit guard** (prevent double-submit): `useSubmitLock`.
|
||||
- **Tabbed forms**: `ScrollSpyTabs`.
|
||||
|
||||
# Tags & status indicators
|
||||
|
||||
Four core-ui components cover tag/status display in tables and lists. Pick by **what the value means**, not by how it looks — don't reach for a generic antd `Tag`:
|
||||
|
||||
- **`StatusTag`** — semantic status with a **dynamic message/detail** (tooltip, download, extra content). Use when a row's status carries variable text, e.g. a failed job with an error message. Colors come from `StatusColorMap` (error/warning/transitioning/success/inactive).
|
||||
- **`StatusDot`** — colored dot + short label, **no message**. Use for a plain status/type cell where the value is a fixed enum (e.g. an event-type or log column). Same `StatusColorMap` palette; `inactive` dot is quaternary. If the status needs dynamic text, use `StatusTag` instead.
|
||||
- **`ThemeTag`** — a **standalone category label** (independent content, e.g. a permission scope or a model name). Default neutral; wraps antd `Tag`.
|
||||
- **`TextAttribute`** — a small neutral pill that is a **subordinate annotation following a primary text** (e.g. `key-name [custom]`), not a standalone tag. Manages its own leading margin. Two variants: `filled` (default) and `outlined`. Ref the name column in `src/pages/api-keys/hooks/use-keys-columns.tsx`.
|
||||
|
||||
Rule of thumb: semantic + dynamic text → `StatusTag`; semantic + fixed enum → `StatusDot`; independent category → `ThemeTag`; annotation of nearby text → `TextAttribute`.
|
||||
|
||||
# Dynamic add-item form fields
|
||||
|
||||
When building a form, select the add-item component from the **shape of the field's data** (its schema). Match the schema, don't hand-roll a list UI:
|
||||
|
||||
- **Plain object** (key→value map) → `LabelSelector`.
|
||||
- **String array** → `ListInput`. Ref `src/pages/llmodels/forms/backend-parameters-list.tsx`.
|
||||
- **Object array** → `MetadataList` with a custom item renderer per entry. Ref `src/pages/llmodels/forms/model-lora-list.tsx`.
|
||||
@@ -1,46 +0,0 @@
|
||||
## Create form table list
|
||||
|
||||
## Create a form
|
||||
|
||||
## StatusTag
|
||||
|
||||
Use `StatusTag` from `@gpustack/core-ui` for status display. Do not use `Tag` from `antd` directly.
|
||||
|
||||
1. Define the mapping from business status values to UI status values in the module's `config/index.ts`:
|
||||
|
||||
```ts
|
||||
import { StatusMaps } from '@/config';
|
||||
import { StatusType } from '@/config/types';
|
||||
|
||||
export const XxxStatusValueMap = {
|
||||
Running: 'running',
|
||||
Pending: 'pending',
|
||||
Failed: 'failed'
|
||||
};
|
||||
|
||||
export const XxxStatusLabelMap: Record<string, string> = {
|
||||
[XxxStatusValueMap.Running]: 'Running',
|
||||
[XxxStatusValueMap.Pending]: 'Pending',
|
||||
[XxxStatusValueMap.Failed]: 'Failed'
|
||||
};
|
||||
|
||||
export const status: Record<string, StatusType> = {
|
||||
[XxxStatusValueMap.Running]: StatusMaps.success,
|
||||
[XxxStatusValueMap.Pending]: StatusMaps.transitioning,
|
||||
[XxxStatusValueMap.Failed]: StatusMaps.error
|
||||
};
|
||||
```
|
||||
|
||||
2. In table columns, pass only the UI status value and display text required by `StatusTag`:
|
||||
|
||||
```tsx
|
||||
<StatusTag
|
||||
statusValue={{
|
||||
status: status[value],
|
||||
text: XxxStatusLabelMap[value] || value,
|
||||
message: record.state_message
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
`statusValue.status` must be a value mapped from `StatusMaps`, such as `success`, `transitioning`, `warning`, `error`, or `inactive`. Do not pass business status values such as `running` or `pending` directly.
|
||||
@@ -1,6 +1,6 @@
|
||||
# GPUStack UI
|
||||
# MesaStack UI
|
||||
|
||||
UI for [GPUStack](https://github.com/gpustack/gpustack).
|
||||
UI for [MesaStack](https://github.com/gpustack/gpustack).
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export default defineConfig({
|
||||
antd: {
|
||||
style: 'less'
|
||||
},
|
||||
title: 'GPUStack',
|
||||
title: 'ZStack AIOS',
|
||||
hash: true,
|
||||
access: {},
|
||||
model: {},
|
||||
|
||||
@@ -100,6 +100,15 @@ const baseRoutes = [
|
||||
path: '/models',
|
||||
redirect: '/models/deployments'
|
||||
},
|
||||
{
|
||||
name: 'userModels',
|
||||
path: '/models/user-models',
|
||||
key: 'userModels',
|
||||
icon: 'icon-models',
|
||||
selectedIcon: 'icon-models-filled',
|
||||
defaultIcon: 'icon-models',
|
||||
component: './llmodels/user-models'
|
||||
},
|
||||
{
|
||||
name: 'modelCatalog',
|
||||
path: '/models/catalog',
|
||||
@@ -110,16 +119,6 @@ const baseRoutes = [
|
||||
access: 'canSeeOrgAdmin',
|
||||
component: './llmodels/catalog'
|
||||
},
|
||||
{
|
||||
name: 'userModels',
|
||||
path: '/models/user-models',
|
||||
key: 'userModels',
|
||||
icon: 'icon-models',
|
||||
selectedIcon: 'icon-models-filled',
|
||||
defaultIcon: 'icon-models',
|
||||
access: 'canSeeUser',
|
||||
component: './llmodels/user-models'
|
||||
},
|
||||
{
|
||||
name: 'deployment',
|
||||
path: '/models/deployments',
|
||||
@@ -274,7 +273,7 @@ const baseRoutes = [
|
||||
selectedIcon: 'icon-cluster2-filled',
|
||||
defaultIcon: 'icon-cluster2-outline',
|
||||
component: './cluster-management/clusters',
|
||||
subMenu: ['/resources/clusters/detail', '/resources/clusters/create']
|
||||
subMenu: ['/resources/clusters/create']
|
||||
},
|
||||
{
|
||||
name: 'workers',
|
||||
@@ -302,16 +301,6 @@ const baseRoutes = [
|
||||
selectedIcon: 'icon-credential-filled',
|
||||
defaultIcon: 'icon-credential-outline',
|
||||
component: './cluster-management/credentials'
|
||||
},
|
||||
{
|
||||
name: 'clusterDetail',
|
||||
path: '/resources/clusters/detail',
|
||||
key: 'clusterDetail',
|
||||
icon: 'icon-cluster2-outline',
|
||||
selectedIcon: 'icon-cluster2-filled',
|
||||
defaultIcon: 'icon-cluster2-outline',
|
||||
hideInMenu: true,
|
||||
component: './cluster-management/cluster-detail'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.42",
|
||||
"@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",
|
||||
|
||||
@@ -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.42
|
||||
version: 1.0.42(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.42':
|
||||
resolution: {integrity: sha512-upMClTHU+xAqd8dlx0w1S9XWHlog5g1hcOCulTk2rmMXqgl66QHfqhEFhJnWIGe1xc8tEj6rW3r5Sirif+qswA==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.42.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.42(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: 3.0 KiB After Width: | Height: | Size: 587 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 565 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 587 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 565 B |
@@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# rebrand: 把面向用户的品牌标识从 GPUStack 批量替换为 ZStack AIOS(或自定义品牌)。
|
||||
#
|
||||
# 设计目标:上游每次更新后,在新的 v<version>-lofyer 分支上跑一次即可重新应用品牌定制。
|
||||
#
|
||||
# 只替换「独立的品牌词」FROM,并刻意跳过所有功能性引用:
|
||||
# - 小写 `gpustack` —— npm 包名 (@gpustack/core-ui)、URL、路径、k8s 命名空间、author(大小写敏感,天然不匹配)
|
||||
# - 全大写 `GPUSTACK` —— 常量/环境变量/全局 (GPUSTACK_API_BASE_URL, __GPUSTACK_*__, GPUSTACK_UI_*)(大小写敏感,不匹配)
|
||||
# - 代码标识符 —— getGPUStackPlugin / GPUStackVersionAtom / GPUStackPluginManager / GPUStackLogo 等
|
||||
# (FROM 紧邻字母时视为标识符的一部分,跳过)
|
||||
# - HTTP 头 X-GPUStack-* —— 后端契约 (如 X-GPUStack-Model),跳过
|
||||
#
|
||||
# 匹配规则:FROM 前后都不是字母(独立单词),且不是 `X-` 前缀的头名。
|
||||
#
|
||||
# 环境变量:
|
||||
# FROM 源品牌词(默认 GPUStack)
|
||||
# TO 目标品牌词(默认 ZStack AIOS)
|
||||
# DRY_RUN 设为 1 时只预览将改动的行,不写文件
|
||||
#
|
||||
set -e
|
||||
|
||||
FROM="${FROM:-GPUStack}"
|
||||
TO="${TO:-ZStack AIOS}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
|
||||
log() { echo -e "\033[1;34m[rebrand]\033[0m $*"; }
|
||||
|
||||
# 大小写敏感、单词边界、排除 X- 头前缀的 Perl 正则。
|
||||
# (?<![A-Za-z]) 前面不是字母 (?<!X-) 不是 X- 头 (?![A-Za-z]) 后面不是字母
|
||||
# FROM 为纯字母品牌词(无正则元字符),故直接拼接,不用 \Q\E
|
||||
# —— \Q\E 在「经变量插值进正则」时不会被求值,反而会破坏匹配。
|
||||
PATTERN="(?<![A-Za-z])(?<!X-)${FROM}(?![A-Za-z])"
|
||||
|
||||
# 行级排除:某些 FROM 出现在「与后端契约绑定的字符串」里,改了会破坏运行时逻辑,
|
||||
# 即使是独立单词也必须整行跳过。命中下列任一正则的行不替换。
|
||||
# 已知例外:
|
||||
# - llmodels/hooks/index.ts 用 startsWith() 比对后端返回的英文兼容性消息
|
||||
# ("... does not exist on the GPUStack server ..."),后端仍发 GPUStack,前端不能改。
|
||||
SKIP_LINE_PATTERNS=(
|
||||
'does not exist on the .*server. It'"'"'s recommended'
|
||||
)
|
||||
skip_line() {
|
||||
local line="$1" pat
|
||||
for pat in "${SKIP_LINE_PATTERNS[@]}"; do
|
||||
if echo "$line" | grep -qP "$pat"; then return 0; fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 待处理的已跟踪文本文件(排除 lockfile 与本脚本自身)。
|
||||
mapfile -t files < <(
|
||||
git ls-files -- \
|
||||
'*.ts' '*.tsx' '*.js' '*.jsx' '*.json' '*.less' '*.html' '*.md' \
|
||||
| grep -v 'pnpm-lock.yaml'
|
||||
)
|
||||
|
||||
log "品牌替换: '${FROM}' -> '${TO}'"
|
||||
log "候选文件: ${#files[@]}"
|
||||
|
||||
# 把行级排除合并成一个 perl 正则,供预览与替换共用(经环境变量传入,免去转义)。
|
||||
SKIP_RE=""
|
||||
for pat in "${SKIP_LINE_PATTERNS[@]}"; do
|
||||
SKIP_RE="${SKIP_RE:+${SKIP_RE}|}(?:${pat})"
|
||||
done
|
||||
export REBRAND_SKIP_RE="${SKIP_RE}"
|
||||
export REBRAND_PATTERN="${PATTERN}"
|
||||
export REBRAND_TO="${TO}"
|
||||
|
||||
# 统计命中行(预览/确认用),已扣除被行级排除的行。
|
||||
log "命中行预览(最多 40 行):"
|
||||
grep -rnP "${PATTERN}" "${files[@]}" 2>/dev/null \
|
||||
| { [[ -n "${SKIP_RE}" ]] && grep -vP "${SKIP_RE}" || cat; } \
|
||||
| head -40 || true
|
||||
total=$(grep -rnP "${PATTERN}" "${files[@]}" 2>/dev/null \
|
||||
| { [[ -n "${SKIP_RE}" ]] && grep -vP "${SKIP_RE}" || cat; } | wc -l)
|
||||
log "命中总行数: ${total}"
|
||||
if [[ -n "${SKIP_RE}" ]]; then
|
||||
skipped=$(grep -rnP "${PATTERN}" "${files[@]}" 2>/dev/null | grep -cP "${SKIP_RE}" || true)
|
||||
log "行级排除(后端契约,保留 ${FROM}): ${skipped} 行"
|
||||
fi
|
||||
|
||||
if [[ "${DRY_RUN}" == "1" ]]; then
|
||||
log "DRY_RUN=1:未写入任何文件。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 实际替换(in-place)。命中行级排除的行整行跳过。
|
||||
changed=0
|
||||
for f in "${files[@]}"; do
|
||||
if grep -qP "${PATTERN}" "$f" 2>/dev/null; then
|
||||
perl -i -pe '
|
||||
my $skip = $ENV{REBRAND_SKIP_RE};
|
||||
next if length($skip) && /$skip/;
|
||||
s/$ENV{REBRAND_PATTERN}/$ENV{REBRAND_TO}/g;
|
||||
' "$f"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
log "已修改文件数: ${changed}"
|
||||
log "完成。请用 'git diff' 复核改动。"
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# sync-github: 从上游 GitHub 拉取最新源码,并全量镜像同步到 192.168.0.23 私有源。
|
||||
#
|
||||
# 默认行为(全量镜像 / 强制):
|
||||
# 1. 确保存在 upstream remote 指向 GitHub(不存在则自动添加,URL 不一致则更新)。
|
||||
# 2. 从 upstream 抓取所有分支与 tag(--prune 清理已删除的远端引用)。
|
||||
# 3. 将上游每个分支强制推送到私有源同名分支(force push)。
|
||||
# 4. 将上游所有 tag 强制推送到私有源。
|
||||
#
|
||||
# 可用环境变量:
|
||||
# UPSTREAM_URL 上游 GitHub 仓库地址(默认 https://github.com/gpustack/gpustack-ui.git)
|
||||
# ORIGIN_REMOTE 私有源 remote 名称(默认 origin)
|
||||
# UPSTREAM_REMOTE 上游 remote 名称(默认 upstream)
|
||||
# PRUNE_BRANCHES 设为 1 时,删除私有源上「上游已不存在」的分支(真·镜像,破坏性,默认关闭)
|
||||
# DRY_RUN 设为 1 时,仅打印将要执行的推送动作,不实际推送
|
||||
#
|
||||
set -e
|
||||
|
||||
UPSTREAM_URL="${UPSTREAM_URL:-https://github.com/gpustack/gpustack-ui.git}"
|
||||
ORIGIN_REMOTE="${ORIGIN_REMOTE:-origin}"
|
||||
UPSTREAM_REMOTE="${UPSTREAM_REMOTE:-upstream}"
|
||||
PRUNE_BRANCHES="${PRUNE_BRANCHES:-0}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
|
||||
log() { echo -e "\033[1;34m[sync-github]\033[0m $*"; }
|
||||
warn() { echo -e "\033[1;33m[sync-github]\033[0m $*" >&2; }
|
||||
|
||||
run() {
|
||||
if [[ "${DRY_RUN}" == "1" ]]; then
|
||||
echo " (dry-run) git $*"
|
||||
else
|
||||
git "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# 1. 确保 upstream remote 指向 GitHub。
|
||||
if git remote get-url "${UPSTREAM_REMOTE}" >/dev/null 2>&1; then
|
||||
current_url=$(git remote get-url "${UPSTREAM_REMOTE}")
|
||||
if [[ "${current_url}" != "${UPSTREAM_URL}" ]]; then
|
||||
log "更新 ${UPSTREAM_REMOTE} 地址: ${current_url} -> ${UPSTREAM_URL}"
|
||||
git remote set-url "${UPSTREAM_REMOTE}" "${UPSTREAM_URL}"
|
||||
fi
|
||||
else
|
||||
log "添加 upstream remote: ${UPSTREAM_REMOTE} -> ${UPSTREAM_URL}"
|
||||
git remote add "${UPSTREAM_REMOTE}" "${UPSTREAM_URL}"
|
||||
fi
|
||||
|
||||
origin_url=$(git remote get-url "${ORIGIN_REMOTE}")
|
||||
log "上游 (拉取): ${UPSTREAM_URL}"
|
||||
log "私有源 (推送): ${origin_url}"
|
||||
|
||||
# 2. 抓取上游所有分支与 tag。
|
||||
log "抓取上游分支与 tag..."
|
||||
git fetch --prune --tags "${UPSTREAM_REMOTE}"
|
||||
|
||||
# 3. 逐个分支强制推送到私有源。
|
||||
log "强制同步分支到私有源..."
|
||||
upstream_branches=$(git for-each-ref --format='%(refname:strip=3)' "refs/remotes/${UPSTREAM_REMOTE}/" | grep -v '^HEAD$')
|
||||
|
||||
for branch in ${upstream_branches}; do
|
||||
log " -> ${branch}"
|
||||
run push --force "${ORIGIN_REMOTE}" \
|
||||
"refs/remotes/${UPSTREAM_REMOTE}/${branch}:refs/heads/${branch}"
|
||||
done
|
||||
|
||||
# 4. 强制同步所有 tag。
|
||||
log "强制同步 tag 到私有源..."
|
||||
run push --force --tags "${ORIGIN_REMOTE}"
|
||||
|
||||
# 5. 可选:删除私有源上、上游已不存在的分支(真·镜像)。
|
||||
if [[ "${PRUNE_BRANCHES}" == "1" ]]; then
|
||||
warn "PRUNE_BRANCHES=1:将删除私有源上上游已不存在的分支"
|
||||
origin_branches=$(git ls-remote --heads "${ORIGIN_REMOTE}" | sed 's@.*refs/heads/@@')
|
||||
for branch in ${origin_branches}; do
|
||||
if ! echo "${upstream_branches}" | grep -qx "${branch}"; then
|
||||
warn " 删除私有源分支: ${branch}"
|
||||
run push "${ORIGIN_REMOTE}" --delete "${branch}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
log "同步完成。"
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<defs>
|
||||
<!-- 顶面填充(亮面,高透明度) -->
|
||||
<linearGradient id="cube-top-grad" x1="12" y1="2" x2="12" y2="12" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#f759ab" stop-opacity="0.18"/>
|
||||
<stop offset="100%" stop-color="#c41d7f" stop-opacity="0.1"/>
|
||||
</linearGradient>
|
||||
<!-- 左侧面填充(暗面,低透明度) -->
|
||||
<linearGradient id="cube-left-grad" x1="2" y1="7" x2="12" y2="17" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#c41d7f" stop-opacity="0.12"/>
|
||||
<stop offset="100%" stop-color="#c41d7f" stop-opacity="0.04"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cube-stroke-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#f759ab"/>
|
||||
<stop offset="100%" stop-color="#c41d7f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- 顶面填充 -->
|
||||
<polygon points="12,2 21.5,6.7 12,11.5 2.5,6.7" fill="url(#cube-top-grad)" />
|
||||
<!-- 左侧面填充 -->
|
||||
<polygon points="2.5,6.7 12,11.5 12,21.3 2.5,16.5" fill="url(#cube-left-grad)" />
|
||||
<!-- 立方体全纯线外骨架(细化为圆角衔接) -->
|
||||
<path d="M12 2L2.5 6.7M12 2l9.5 4.7M21.5 6.7L12 11.5M2.5 6.7L12 11.5M2.5 6.7v9.8l9.5 4.8M21.5 6.7v9.8l-9.5 4.8M12 11.5v9.8" stroke="url(#cube-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<defs>
|
||||
<!-- 1. 定义专属微通透渐变填充 -->
|
||||
<linearGradient id="img-fill-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#d46b08" stop-opacity="0.12"/>
|
||||
<stop offset="100%" stop-color="#d46b08" stop-opacity="0.04"/>
|
||||
</linearGradient>
|
||||
<!-- 2. 定义边框高精度渐变(亮橙到深橙,拉开层次) -->
|
||||
<linearGradient id="img-stroke-grad" x1="4" y1="4" x2="20" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#fa8c16"/>
|
||||
<stop offset="100%" stop-color="#d46b08"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- 3. 精装底色充填层 -->
|
||||
<rect x="3" y="3" width="18" height="18" rx="4" fill="url(#img-fill-grad)" />
|
||||
<!-- 4. 高级柔和微圆角边框层 -->
|
||||
<rect x="3" y="3" width="18" height="18" rx="4" stroke="url(#img-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- 内部几何现代山脉线条 -->
|
||||
<path d="M3 16l4-4a2 2 0 0 1 2.8 0l5.2 5.2M13 15l2.5-2.5a2 2 0 0 1 2.8 0l2.7 2.7" stroke="url(#img-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- 标志性通透小钻石 -->
|
||||
<rect x="14" y="6" width="4" height="4" rx="1.5" transform="rotate(45 16 8)" fill="#fa8c16" fill-opacity="0.3" stroke="url(#img-stroke-grad)" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="chat-fill-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#389e0d" stop-opacity="0.1"/>
|
||||
<stop offset="100%" stop-color="#389e0d" stop-opacity="0.02"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="chat-stroke-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#73d13d"/>
|
||||
<stop offset="100%" stop-color="#389e0d"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- 主现代对话框体(全部改为圆润R角,精装填充) -->
|
||||
<path d="M18 4H6a3 3 0 0 0-3 3v8a3 3 0 0 0 3 3h7.5l3.5 3.5a1 1 0 0 0 1.5-.5V17a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3z" fill="url(#chat-fill-grad)" stroke="url(#chat-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- 内部通透对话线条(细化为圆角代码采样块感) -->
|
||||
<rect x="7" y="8" width="8" height="1.5" rx="0.75" fill="#73d13d" fill-opacity="0.3"/>
|
||||
<rect x="7" y="11.5" width="10" height="1.5" rx="0.75" fill="#73d13d" fill-opacity="0.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 640 B |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="rank-fill-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#08979c" stop-opacity="0.1"/>
|
||||
<stop offset="100%" stop-color="#08979c" stop-opacity="0.01"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rank-stroke-grad" x1="0" y1="0" x2="24" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#36cfc9"/>
|
||||
<stop offset="100%" stop-color="#08979c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- 数据权重条(全部改为高级圆角) -->
|
||||
<rect x="11" y="4" width="10" height="2.5" rx="1.25" fill="#36cfc9" fill-opacity="0.1" stroke="url(#rank-stroke-grad)" stroke-width="1.5"/>
|
||||
<rect x="11" y="9" width="7.5" height="2.5" rx="1.25" fill="#36cfc9" fill-opacity="0.05" stroke="url(#rank-stroke-grad)" stroke-width="1.5"/>
|
||||
<rect x="11" y="14" width="5" height="2.5" rx="1.25" stroke="url(#rank-stroke-grad)" stroke-width="1.5"/>
|
||||
<!-- 基准线与立体双向指引箭头(优化为圆角) -->
|
||||
<path d="M3 17l3 3 3-3M6 4v16" stroke="url(#rank-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.5 5.5L6 4l1.5 1.5" stroke="url(#rank-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 801 B |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="stt-fill-grad" x1="12" y1="3" x2="12" y2="14" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#40a9ff" stop-opacity="0.2"/>
|
||||
<stop offset="100%" stop-color="#1677ff" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="stt-stroke-grad" x1="12" y1="3" x2="12" y2="21" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#40a9ff"/>
|
||||
<stop offset="100%" stop-color="#1677ff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- 麦克风核心体:精装通透填充 -->
|
||||
<rect x="8.5" y="3" width="7" height="11" rx="3.5" fill="url(#stt-fill-grad)" stroke="url(#stt-stroke-grad)" stroke-width="1.8" />
|
||||
<!-- 内部音膜立体结构线(细化为点状) -->
|
||||
<line x1="10" y1="8" x2="14" y2="8" stroke="url(#stt-stroke-grad)" stroke-width="1" stroke-dasharray="1 2"/>
|
||||
<!-- 悬挂外托架与底座(全部改为高级圆角) -->
|
||||
<path d="M5 10a7 7 0 0 0 14 0M12 17v4M8 21h8" stroke="url(#stt-stroke-grad)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<defs>
|
||||
<!-- 1. 核心高通透深蓝渐变充填 -->
|
||||
<linearGradient id="tts-v2-fill" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#2f54eb" stop-opacity="0.15"/>
|
||||
<stop offset="100%" stop-color="#1d39c4" stop-opacity="0.03"/>
|
||||
</linearGradient>
|
||||
<!-- 2. 精准调校的专属蓝色渐变边框 -->
|
||||
<linearGradient id="tts-v2-stroke" x1="4" y1="4" x2="20" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#2f54eb"/>
|
||||
<stop offset="100%" stop-color="#1d39c4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 左侧:低频辅助声波(大间距,带现代圆角) -->
|
||||
<rect x="4" y="8" width="2.2" height="8" rx="1.1" stroke="url(#tts-v2-stroke)" stroke-width="1.8" stroke-linecap="round"/>
|
||||
|
||||
<!-- 中央:核心高频声波主体(拉大宽度,注入通透水晶质感) -->
|
||||
<rect x="10.4" y="2" width="3.2" height="20" rx="1.6" fill="url(#tts-v2-fill)" stroke="url(#tts-v2-stroke)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- 右侧:高频衰减声波(保持几何对称与呼吸感) -->
|
||||
<rect x="17.8" y="5" width="2.2" height="14" rx="1.1" stroke="url(#tts-v2-stroke)" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,3 @@
|
||||
.ant-alert-with-description .ant-alert-title {
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import './table.less';
|
||||
@import './alert.less';
|
||||
|
||||
.m-b-20 {
|
||||
margin-bottom: 20px;
|
||||
|
||||
@@ -9,6 +9,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tableEmptyFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-table {
|
||||
.ant-table {
|
||||
.ant-table-container {
|
||||
@@ -18,5 +27,27 @@
|
||||
scrollbar-color: var(--color-scrollbar-thumb) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve a stable block for the empty/loading state so the first-load
|
||||
// spinner and the empty result occupy the same height as eventual data —
|
||||
// this removes the layout jump when entering the page. Scoped to
|
||||
// `.ant-table-content` so it only targets x-scroll tables (whose empty
|
||||
// row lives here) and leaves fixed-height `scroll.y` tables untouched.
|
||||
// Height must match the `minHeight` passed to <NoResult> in
|
||||
// use-no-resource-result.
|
||||
.ant-table-content {
|
||||
.ant-table-placeholder {
|
||||
> .ant-table-cell {
|
||||
height: calc(100vh - 300px);
|
||||
}
|
||||
|
||||
// NoResult renders nothing while loading and mounts an <Empty> only
|
||||
// once the request settles, so this fires exactly when the empty
|
||||
// state appears — a seamless fade-in instead of a hard pop.
|
||||
.ant-empty {
|
||||
animation: tableEmptyFadeIn 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { GPUStackVersionAtom } from '@/atoms/user';
|
||||
import { getAtomStorage } from '@/atoms/utils';
|
||||
import VersionInfo, { modalConfig } from '@/components/version-info';
|
||||
import externalLinks from '@/constants/external-links';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, Divider, Modal, Typography } from 'antd';
|
||||
import { Divider, Typography } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const CompanyWrapper = styled.div`
|
||||
@@ -33,20 +31,11 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const { styles } = useStyles();
|
||||
|
||||
const showVersion = () => {
|
||||
modal.info({
|
||||
...modalConfig,
|
||||
width: 460,
|
||||
content: <VersionInfo intl={intl} />
|
||||
});
|
||||
};
|
||||
const version = useAtomValue(GPUStackVersionAtom);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<div className={styles.footer}>
|
||||
<div className="footer-content">
|
||||
<div className="footer-content-left">
|
||||
@@ -63,18 +52,7 @@ const Footer: React.FC = () => {
|
||||
</Typography.Link>
|
||||
</CompanyWrapper>
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
href={externalLinks.documentation}
|
||||
target="_blank"
|
||||
>
|
||||
{intl.formatMessage({ id: 'common.button.help' })}
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
<Button type="link" size="small" onClick={showVersion}>
|
||||
{getAtomStorage(GPUStackVersionAtom)?.version}
|
||||
</Button>
|
||||
<span>{version?.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import Logo from '@/assets/images/gpustack-logo.png';
|
||||
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
|
||||
import externalLinks from '@/constants/external-links';
|
||||
import { useLogo } from '@/hooks/use-logo';
|
||||
import { Button } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import React from 'react';
|
||||
@@ -18,7 +19,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
|
||||
isProd,
|
||||
isDev
|
||||
} = gpuStackVersionAtom;
|
||||
|
||||
const { sidebarLogo } = useLogo();
|
||||
// user info
|
||||
const { is_admin } = userDataAtom || {};
|
||||
|
||||
@@ -30,7 +31,7 @@ const VersionInfo: React.FC<{ intl: any }> = ({ intl }) => {
|
||||
return (
|
||||
<div className="version-box">
|
||||
<div className="img">
|
||||
<img src={Logo} alt="logo" />
|
||||
<img src={sidebarLogo || Logo} alt="logo" />
|
||||
</div>
|
||||
|
||||
<div className="ver">
|
||||
|
||||
@@ -28,6 +28,7 @@ export default {
|
||||
rowSelectedBg: 'transparent',
|
||||
headerSortActiveBg: 'transparent',
|
||||
headerSortHoverBg: 'transparent',
|
||||
bodySortBg: 'transparent',
|
||||
headerBg: 'none'
|
||||
},
|
||||
Button: {
|
||||
@@ -42,6 +43,10 @@ export default {
|
||||
DatePicker: {
|
||||
fontSizeLG: 14
|
||||
},
|
||||
Alert: {
|
||||
withDescriptionPadding: '12px 16px',
|
||||
withDescriptionIconSize: 18
|
||||
},
|
||||
Menu: {
|
||||
iconSize: 16,
|
||||
iconMarginInlineEnd: 12,
|
||||
|
||||
@@ -26,11 +26,12 @@ export default {
|
||||
cellPaddingInline: 16,
|
||||
cellPaddingBlock: 6,
|
||||
cellFontSize: 14,
|
||||
rowSelectedHoverBg: 'rgb(249 249 249)',
|
||||
rowHoverBg: 'rgb(249 249 249)',
|
||||
rowSelectedHoverBg: 'rgb(247 247 247)',
|
||||
rowHoverBg: 'rgb(247 247 247)',
|
||||
rowSelectedBg: 'transparent',
|
||||
headerSortActiveBg: 'transparent',
|
||||
headerSortHoverBg: 'transparent',
|
||||
bodySortBg: 'transparent',
|
||||
headerSplitColor: '#e8e8e8',
|
||||
headerBg: 'none'
|
||||
},
|
||||
@@ -46,6 +47,13 @@ export default {
|
||||
DatePicker: {
|
||||
fontSizeLG: 14
|
||||
},
|
||||
Alert: {
|
||||
withDescriptionPadding: '12px 16px',
|
||||
withDescriptionIconSize: 18
|
||||
},
|
||||
Card: {
|
||||
headerHeight: 50
|
||||
},
|
||||
Menu: {
|
||||
iconSize: 16,
|
||||
iconMarginInlineEnd: 12,
|
||||
@@ -115,7 +123,7 @@ export default {
|
||||
borderRadiusLG: 6,
|
||||
borderRadius: 4,
|
||||
borderRadiusSM: 3,
|
||||
colorBgContainer: '#fff',
|
||||
colorBgContainer: '#fdfdfd',
|
||||
fontSize: 14,
|
||||
motion: true
|
||||
}
|
||||
|
||||
@@ -21,8 +21,6 @@ html {
|
||||
--color-fill-sider: #f4f5f4;
|
||||
--color-bg-1: #f4f5f4;
|
||||
--color-scroll-bg: #d9d9d9;
|
||||
--color-fill-2: #fff;
|
||||
--color-fill-3: #f3f6fa;
|
||||
--color-logs-bg: #1e1e1e;
|
||||
--color-logs-text: #d4d4d4;
|
||||
--layout-content-blockpadding: 24px;
|
||||
@@ -76,7 +74,6 @@ html {
|
||||
--ant-rate-star-color: #fadb14;
|
||||
--color-fill-spin-bg: rgba(255, 255, 255, 15%);
|
||||
--width-tooltip-max: 420px;
|
||||
--color-bg-tooltip: '#fff';
|
||||
--color-modal-content-bg: rgba(255, 255, 255, 90%);
|
||||
--color-modal-box-shadow: 0 4px 16px rgba(0, 0, 0, 10%);
|
||||
--color-spotlight-bg: rgba(255, 255, 255, 100%);
|
||||
@@ -91,6 +88,7 @@ html {
|
||||
// ======== container ============
|
||||
--color-border-container: #ededed;
|
||||
--color-text-table-header: #71717a;
|
||||
--seal-table-row-min-height: 68px;
|
||||
}
|
||||
|
||||
html[data-theme='realDark'] {
|
||||
@@ -100,7 +98,6 @@ html[data-theme='realDark'] {
|
||||
--color-editor-dark: #00101f;
|
||||
--color-editor-light: #fafafa;
|
||||
--color-fill-spin-bg: rgba(55, 55, 55, 50%);
|
||||
--color-bg-tooltip: #424242;
|
||||
--color-editor-header-bg: #292929;
|
||||
--color-progress-text: rgba(255, 255, 255, 80%);
|
||||
--color-modal-content-bg: #1f1f1f;
|
||||
@@ -115,19 +112,9 @@ html[data-theme='realDark'] {
|
||||
.ant-result-image {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ant-pro-page-container-affix .ant-pro-page-container-warp {
|
||||
background-color: #292929 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='light'] {
|
||||
.ant-pro-page-container-affix .ant-pro-page-container-warp {
|
||||
background-color: #fff !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
background-color: #f4f5f6;
|
||||
}
|
||||
|
||||
@@ -210,7 +197,7 @@ body {
|
||||
|
||||
tr > td {
|
||||
border-bottom: none;
|
||||
height: 68px;
|
||||
height: var(--seal-table-row-min-height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,3 +838,6 @@ body {
|
||||
padding-right: 8px;
|
||||
padding-block: 8px;
|
||||
}
|
||||
.ant-select-multiple .ant-select-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -8,24 +8,38 @@ const findValidJSONStrings = (inputStr: string) => {
|
||||
const openingBraceIndex = inputStr.indexOf('{', startIndex);
|
||||
if (openingBraceIndex === -1) break; // No more opening braces
|
||||
|
||||
let closingBraceIndex = openingBraceIndex;
|
||||
// find the matching closing brace, ignoring braces inside string
|
||||
// literals (e.g. a state_message containing `{`/`}`)
|
||||
let closingBraceIndex = -1;
|
||||
let braceCount = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
// find couple of braces
|
||||
while (closingBraceIndex < inputStr.length) {
|
||||
if (inputStr[closingBraceIndex] === '{') {
|
||||
for (let i = openingBraceIndex; i < inputStr.length; i++) {
|
||||
const char = inputStr[i];
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === '\\') {
|
||||
escaped = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
} else if (char === '"') {
|
||||
inString = true;
|
||||
} else if (char === '{') {
|
||||
braceCount++;
|
||||
} else if (inputStr[closingBraceIndex] === '}') {
|
||||
} else if (char === '}') {
|
||||
braceCount--;
|
||||
if (braceCount === 0) {
|
||||
closingBraceIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (braceCount === 0) {
|
||||
break;
|
||||
}
|
||||
closingBraceIndex++;
|
||||
}
|
||||
|
||||
if (braceCount !== 0) {
|
||||
// no matching closing brace
|
||||
if (closingBraceIndex === -1) {
|
||||
// no matching closing brace yet, wait for more data
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -37,11 +51,11 @@ const findValidJSONStrings = (inputStr: string) => {
|
||||
try {
|
||||
const parsedData = JSON.parse(jsonString);
|
||||
validJSONStrings.push(parsedData);
|
||||
startIndex = closingBraceIndex + 1;
|
||||
} catch (error) {
|
||||
// mabye invalid JSON
|
||||
break;
|
||||
// skip the malformed segment instead of breaking, otherwise it jams
|
||||
// the buffer and every later event on this stream is lost
|
||||
}
|
||||
startIndex = closingBraceIndex + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export default function useActions<T>(actions: Global.ActionItem<T>[], ctx: T) {
|
||||
return actions
|
||||
.filter((action) => {
|
||||
return action.visible ? action.visible(ctx) : true;
|
||||
})
|
||||
.map((action) => ({
|
||||
...action,
|
||||
disabled: action.disabled?.(ctx)
|
||||
}));
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { message } from 'antd';
|
||||
|
||||
type MessageType = 'input' | 'select';
|
||||
|
||||
const useAppUtils = () => {
|
||||
const intl = useIntl();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
/**
|
||||
*
|
||||
* @param type Array<'input' | 'select'>
|
||||
* @param name
|
||||
* @param locale boolean
|
||||
* @returns
|
||||
*/
|
||||
const getRuleMessage = (
|
||||
type: MessageType | MessageType[],
|
||||
name: string,
|
||||
locale = true
|
||||
) => {
|
||||
const nameStr = locale ? intl.formatMessage({ id: name }) : name;
|
||||
// transform type to array
|
||||
const typeList = Array.isArray(type) ? type : [type];
|
||||
|
||||
if (typeList.includes('select') && typeList.includes('input')) {
|
||||
return intl.formatMessage(
|
||||
{ id: 'common.form.rule.selectInput' },
|
||||
{ name: nameStr }
|
||||
);
|
||||
}
|
||||
|
||||
if (typeList.includes('input')) {
|
||||
return intl.formatMessage(
|
||||
{ id: 'common.form.rule.input' },
|
||||
{ name: nameStr }
|
||||
);
|
||||
}
|
||||
|
||||
return intl.formatMessage(
|
||||
{ id: 'common.form.rule.select' },
|
||||
{ name: nameStr }
|
||||
);
|
||||
};
|
||||
|
||||
const showSuccess = (msg?: string) => {
|
||||
messageApi.success(
|
||||
msg || intl.formatMessage({ id: 'common.message.success' })
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
getRuleMessage,
|
||||
showSuccess
|
||||
};
|
||||
};
|
||||
|
||||
export default useAppUtils;
|
||||
@@ -1,18 +0,0 @@
|
||||
// broadcast channel hook
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const useBroadcast = () => {
|
||||
const broadcastChannel = useRef<BroadcastChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
broadcastChannel.current = new BroadcastChannel('broadcast_channel');
|
||||
|
||||
return () => {
|
||||
broadcastChannel.current?.close();
|
||||
console.log('broadcast channel closed');
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { broadcastChannel };
|
||||
};
|
||||
@@ -43,8 +43,11 @@ export const createAxiosToken = (): CancelTokenSource => {
|
||||
};
|
||||
|
||||
export const sliceData = (data: string, loaded: number, loadedSize: any) => {
|
||||
// `loaded` is a byte count while `data` is a UTF-16 string; with any
|
||||
// non-ASCII payload the two drift apart, so track consumed characters by
|
||||
// string length only
|
||||
const result = data.slice(loadedSize.current);
|
||||
loadedSize.current = loaded;
|
||||
loadedSize.current = data.length;
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export default function useContainerScroll(
|
||||
container: any,
|
||||
options?: { toBottom?: boolean }
|
||||
) {
|
||||
const isWheeled = useRef(false);
|
||||
const scroller = useRef(container);
|
||||
const optionsRef = useRef(options);
|
||||
const toBottomFlag = useRef(options?.toBottom);
|
||||
const timerRef = useRef<any>(null);
|
||||
|
||||
const debunceResetWheeled = _.debounce(() => {
|
||||
isWheeled.current = false;
|
||||
}, 5000);
|
||||
|
||||
const handleContentWheel = (e: any) => {
|
||||
isWheeled.current = true;
|
||||
debunceResetWheeled.cancel?.();
|
||||
debunceResetWheeled();
|
||||
};
|
||||
|
||||
const scrollerRun = () => {
|
||||
const scrollerContainer = scroller.current?.current || {};
|
||||
const { scrollHeight, clientHeight, scrollTop } = scrollerContainer;
|
||||
if (
|
||||
optionsRef.current?.toBottom &&
|
||||
toBottomFlag.current &&
|
||||
scrollHeight > clientHeight + scrollTop
|
||||
) {
|
||||
scroller.current.current.scrollTop = scrollHeight;
|
||||
// toBottomFlag.current = false;
|
||||
isWheeled.current = false;
|
||||
} else if (
|
||||
!isWheeled.current &&
|
||||
scrollHeight > clientHeight + scrollTop &&
|
||||
scroller.current?.current
|
||||
) {
|
||||
scroller.current.current.scrollTop += 10;
|
||||
window.requestAnimationFrame(scrollerRun);
|
||||
}
|
||||
};
|
||||
|
||||
const updateScrollerPosition = () => {
|
||||
if (!isWheeled.current) {
|
||||
window.requestAnimationFrame(scrollerRun);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleContentWheel,
|
||||
updateScrollerPosition,
|
||||
scroller
|
||||
};
|
||||
}
|
||||
@@ -1,54 +1,132 @@
|
||||
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
|
||||
import { converter, modeHsl, modeRgb, useMode } from 'culori/fn';
|
||||
import { clampChroma, formatHex, modeOklch, modeRgb, useMode } from 'culori/fn';
|
||||
import useUserSettings from './use-user-settings';
|
||||
|
||||
const toHsl = converter('hsl');
|
||||
|
||||
const DEFAULT_BRAND_HUE = 211; // brand color
|
||||
const COOL_HUE_START = 180;
|
||||
const COOL_HUE_END = 280;
|
||||
// Linear-style minimal/neutral palette: a TIGHT blue -> violet band in OKLCH
|
||||
// (perceptually uniform), kept moderate-chroma so fills read as clean and gentle
|
||||
// rather than candy-colored. We deliberately stay narrow and DON'T fan out to
|
||||
// green/magenta — in this aesthetic series are separated by LIGHTNESS, not by
|
||||
// spreading across the hue wheel. Every series (including the first) is generated
|
||||
// from this one ramp, so the whole palette stays in a single cohesive color
|
||||
// family — no special high-saturation brand color that clashes with the rest.
|
||||
const COOL_HUE_START = 250; // blue
|
||||
const COOL_HUE_END = 315; // violet (stops short of magenta/pink, stays neutral-cool)
|
||||
const COOL_HUE_RANGE = COOL_HUE_END - COOL_HUE_START;
|
||||
const GOLDEN_RATIO_CONJUGATE = 0.618033988749895;
|
||||
|
||||
function colorStringToHue(input?: string): number | undefined {
|
||||
if (!input) return undefined;
|
||||
const color = toHsl(input);
|
||||
if (!color || typeof color.h !== 'number') return undefined;
|
||||
return color.h;
|
||||
}
|
||||
|
||||
export default function useCoolColors() {
|
||||
// const brandHue = useMemo(
|
||||
// () => colorStringToHue(userSettings.colorPrimary) ?? DEFAULT_BRAND_HUE,
|
||||
// [userSettings.colorPrimary]
|
||||
// );
|
||||
/**
|
||||
* Vivid, distinct cool accents — for places that need a handful of "primary"
|
||||
* colors, one per card/section (e.g. the summary trend cards), NOT a stacked
|
||||
* multi-series palette. Every color is anchor-quality (bright + saturated) and
|
||||
* spread evenly across the blue→violet band, so the set reads as several equally
|
||||
* strong primaries rather than one bold + several washed-out fills.
|
||||
*/
|
||||
export function useCoolAccents() {
|
||||
useMode(modeRgb);
|
||||
useMode(modeHsl);
|
||||
useMode(modeOklch);
|
||||
|
||||
const brandHue = DEFAULT_BRAND_HUE;
|
||||
const { isDarkTheme } = useUserSettings();
|
||||
|
||||
return useMemoizedFn((count: number): string[] => {
|
||||
if (count <= 0) return [];
|
||||
|
||||
const colors: string[] = [];
|
||||
const l = isDarkTheme ? 0.62 : 0.64;
|
||||
const c = isDarkTheme ? 0.16 : 0.2;
|
||||
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
let hue: number;
|
||||
const t = count <= 1 ? 0 : i / (count - 1);
|
||||
const hue = COOL_HUE_START + t * COOL_HUE_RANGE;
|
||||
out.push(
|
||||
formatHex(clampChroma({ mode: 'oklch', l, c, h: hue }, 'oklch'))
|
||||
);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
hue = brandHue - 5;
|
||||
} else {
|
||||
const offset = (i * GOLDEN_RATIO_CONJUGATE) % 1;
|
||||
hue = COOL_HUE_START + offset * COOL_HUE_RANGE;
|
||||
export default function useCoolColors() {
|
||||
useMode(modeRgb);
|
||||
useMode(modeOklch);
|
||||
|
||||
if (Math.abs(hue - brandHue) < 5) {
|
||||
hue = (hue + 10) % COOL_HUE_END;
|
||||
}
|
||||
}
|
||||
const { isDarkTheme } = useUserSettings();
|
||||
|
||||
const s = i === 0 ? 100 : 75 + (i % 3) * 5;
|
||||
const l = i === 0 ? 50 : 55 + (i % 2) * 5;
|
||||
return useMemoizedFn((count: number): string[] => {
|
||||
if (count <= 0) return [];
|
||||
|
||||
colors.push(`hsl(${Math.round(hue)}, ${s}%, ${l}%)`);
|
||||
// Moderate chroma: clean and crisp, but gentle (not neon). Too low reads as
|
||||
// muddy/dirty; too high reads as harsh. Dark mode a touch lower so fills
|
||||
// stay calm against the dark canvas.
|
||||
const baseChroma = isDarkTheme ? 0.085 : 0.12;
|
||||
|
||||
// First series is the "primary" anchor: SAME blue family (the bluest end of
|
||||
// the ramp, nearest the brand hue) but clearly brighter and more saturated —
|
||||
// a vivid, clean brand-blue that reads as the base color. The rest of the
|
||||
// palette stays low-chroma, so the anchor pops as the primary while the
|
||||
// family still feels cohesive.
|
||||
const colors: string[] = [
|
||||
formatHex(
|
||||
clampChroma(
|
||||
{
|
||||
mode: 'oklch',
|
||||
l: isDarkTheme ? 0.62 : 0.66,
|
||||
c: isDarkTheme ? 0.16 : 0.2,
|
||||
h: COOL_HUE_START
|
||||
},
|
||||
'oklch'
|
||||
)
|
||||
)
|
||||
];
|
||||
|
||||
const rest = count - 1;
|
||||
if (rest <= 0) return colors;
|
||||
|
||||
// Separation is driven by LIGHTNESS, not hue. The hue band is narrow, so as
|
||||
// the count grows we add lightness TIERS — each tier reuses the same narrow
|
||||
// hue ramp at a distinct lightness level, multiplying how many separable
|
||||
// colors fit while keeping the whole palette in one cohesive family.
|
||||
const tiers = rest <= 6 ? 1 : rest <= 12 ? 2 : 3;
|
||||
const steps = Math.ceil(rest / tiers);
|
||||
|
||||
// Distinct lightness levels per tier count (index 0 → 2 tiers, 1 → 3 tiers).
|
||||
// Lighter, airier levels for a fresh/crisp feel; kept in the upper-mid range
|
||||
// so fills stay clean and legible.
|
||||
const lightTiers = isDarkTheme
|
||||
? [
|
||||
[0.68, 0.5],
|
||||
[0.72, 0.6, 0.48]
|
||||
]
|
||||
: [
|
||||
[0.82, 0.64],
|
||||
[0.84, 0.72, 0.6]
|
||||
];
|
||||
// Single-tier light/dark zig-zag for the common small-count case.
|
||||
const [lightLo, lightHi] = isDarkTheme ? [0.5, 0.68] : [0.64, 0.82];
|
||||
|
||||
for (let i = 0; i < rest; i++) {
|
||||
// Cycle the tier on every step so consecutive series always differ in
|
||||
// lightness — exactly where stacked bars are hardest to tell apart.
|
||||
const tier = i % tiers;
|
||||
const step = Math.floor(i / tiers);
|
||||
|
||||
// Even hue ramp across the narrow band. Each tier is offset by a fraction
|
||||
// of a step so same-step colors in different tiers don't share a hue.
|
||||
const t = steps <= 1 ? 0.5 : (step + tier / tiers) / steps;
|
||||
const hue = COOL_HUE_START + t * COOL_HUE_RANGE;
|
||||
|
||||
// 1 tier (≤6 series): light/dark zig-zag. 2-3 tiers: the tier's level.
|
||||
const lightness =
|
||||
tiers === 1 ? (i % 2 ? lightLo : lightHi) : lightTiers[tiers - 2][tier];
|
||||
|
||||
// Clamp chroma into the sRGB gamut so values don't get distorted by a raw
|
||||
// channel clip when serialized to hex.
|
||||
colors.push(
|
||||
formatHex(
|
||||
clampChroma(
|
||||
{ mode: 'oklch', l: lightness, c: baseChroma, h: hue },
|
||||
'oklch'
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return colors;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const useCopyToClipboard = () => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = useCallback(async (text: string) => {
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
setCopied(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { copied, copyToClipboard };
|
||||
};
|
||||
|
||||
export default useCopyToClipboard;
|
||||
@@ -1,71 +0,0 @@
|
||||
import { HandlerOptions } from '@/hooks/use-chunk-fetch';
|
||||
import { useDownloadStream } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Progress, notification } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const renderMessage = (title: string) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 280,
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const createFileName = (name: string) => {
|
||||
const timestamp = dayjs().format('YYYY-MM-DD_HH-mm-ss');
|
||||
const fileName = `${name}_${timestamp}.txt`;
|
||||
return fileName;
|
||||
};
|
||||
|
||||
const useDownloadLogs = () => {
|
||||
const { downloadStream } = useDownloadStream();
|
||||
const intl = useIntl();
|
||||
const [api, contextHolder] = notification.useNotification({
|
||||
stack: { threshold: 1 }
|
||||
});
|
||||
|
||||
const downloadNotification = (
|
||||
data: HandlerOptions & {
|
||||
filename: string;
|
||||
duration?: number;
|
||||
chunkRequestRef: any;
|
||||
}
|
||||
) => {
|
||||
api.open({
|
||||
duration: data.duration,
|
||||
message: renderMessage(data.filename),
|
||||
key: data.filename,
|
||||
closeIcon: (
|
||||
<span>{intl.formatMessage({ id: 'common.button.cancel' })}</span>
|
||||
),
|
||||
description: <Progress percent={data.percent} size="small"></Progress>,
|
||||
onClose() {
|
||||
data.chunkRequestRef?.current?.abort();
|
||||
notification.destroy?.(data.filename);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadLog = async (params: { url: string; name: string }) => {
|
||||
downloadStream({
|
||||
url: params.url,
|
||||
filename: createFileName(params.name),
|
||||
downloadNotification
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onDownloadLog: handleDownloadLog,
|
||||
contextHolder
|
||||
};
|
||||
};
|
||||
|
||||
export default useDownloadLogs;
|
||||
@@ -1,136 +0,0 @@
|
||||
import useSetChunkFetch, { HandlerOptions } from '@/hooks/use-chunk-fetch';
|
||||
import { message } from 'antd';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function useDownloadStream() {
|
||||
const chunkRequestRef = useRef<any>(null);
|
||||
const logParseWorker = useRef<any>(null);
|
||||
const clearScreen = useRef(false);
|
||||
const filename = useRef('log');
|
||||
const downloadNotificationRef = useRef<any>(null);
|
||||
|
||||
const { setChunkFetch } = useSetChunkFetch();
|
||||
|
||||
const downloadFile = (content: string) => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename.current;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const updateContent = (data: string, options?: HandlerOptions) => {
|
||||
const { isComplete, percent } = options || {};
|
||||
|
||||
logParseWorker.current?.postMessage({
|
||||
inputStr: data,
|
||||
reset: clearScreen.current,
|
||||
isComplete: isComplete,
|
||||
percent: percent,
|
||||
chunked: false
|
||||
});
|
||||
clearScreen.current = false;
|
||||
};
|
||||
|
||||
const handleError = (error: any) => {
|
||||
const errorMsg = error?.message || error;
|
||||
const msg =
|
||||
typeof errorMsg === 'string' ? errorMsg : JSON.stringify(errorMsg);
|
||||
message.error(msg);
|
||||
downloadNotificationRef.current?.({
|
||||
duration: 1,
|
||||
percent: 0,
|
||||
filename: filename.current
|
||||
});
|
||||
};
|
||||
|
||||
const downloadStream = async (props: {
|
||||
data?: any;
|
||||
url: string;
|
||||
params?: any;
|
||||
signal?: AbortSignal;
|
||||
method?: string;
|
||||
headers?: any;
|
||||
filename?: string;
|
||||
downloadNotification?: (data: any) => void;
|
||||
}) => {
|
||||
try {
|
||||
clearScreen.current = true;
|
||||
filename.current = props.filename || 'log';
|
||||
downloadNotificationRef.current = props.downloadNotification;
|
||||
const { params, url } = props;
|
||||
|
||||
chunkRequestRef.current?.current?.abort?.();
|
||||
|
||||
chunkRequestRef.current = setChunkFetch({
|
||||
url,
|
||||
params,
|
||||
watch: false,
|
||||
contentType: 'text',
|
||||
errorHandler: handleError,
|
||||
handler: updateContent
|
||||
});
|
||||
downloadNotificationRef.current?.({
|
||||
filename: filename.current,
|
||||
duration: null,
|
||||
chunkRequestRef: chunkRequestRef.current
|
||||
});
|
||||
} catch (error) {
|
||||
//
|
||||
downloadNotificationRef.current?.({
|
||||
duration: 1,
|
||||
percent: 0,
|
||||
filename: filename.current
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
logParseWorker.current?.terminate?.();
|
||||
|
||||
logParseWorker.current = new Worker(
|
||||
// @ts-ignore
|
||||
new URL('@/components/logs-viewer/parse-worker.ts', import.meta.url),
|
||||
{
|
||||
type: 'module'
|
||||
}
|
||||
);
|
||||
|
||||
logParseWorker.current.onmessage = (event: any) => {
|
||||
const { result, isComplete, percent } = event.data;
|
||||
|
||||
const isAborted = chunkRequestRef.current?.current?.signal?.aborted;
|
||||
if (!isComplete && !isAborted) {
|
||||
downloadNotificationRef.current?.({
|
||||
percent: percent,
|
||||
duration: null,
|
||||
filename: filename.current,
|
||||
chunkRequestRef: chunkRequestRef.current
|
||||
});
|
||||
} else if (isComplete && !isAborted) {
|
||||
downloadNotificationRef.current?.({
|
||||
duration: 1,
|
||||
percent: 100,
|
||||
filename: filename.current
|
||||
});
|
||||
downloadFile(result);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (logParseWorker.current) {
|
||||
logParseWorker.current.terminate();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
downloadStream
|
||||
};
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { driver, type Config } from 'driver.js';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const useDriver = (config?: Config & { id: string }) => {
|
||||
const intl = useIntl();
|
||||
const driverRef = useRef<any>(null);
|
||||
|
||||
const handleDoNotShowAgain = () => {};
|
||||
|
||||
const init = () => {
|
||||
driverRef.current = driver({
|
||||
overlayOpacity: 0.2,
|
||||
animate: false,
|
||||
...config
|
||||
});
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (!driverRef.current) {
|
||||
init();
|
||||
}
|
||||
driverRef.current.drive();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
driverRef.current?.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { start, initDriver: init, driver: driverRef.current };
|
||||
};
|
||||
|
||||
export default useDriver;
|
||||
@@ -1,106 +0,0 @@
|
||||
import HotKeys from '@/config/hotkeys';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { throttle } from 'lodash';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
hintOverlay: css`
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--color-esc-hint-bg);
|
||||
color: ${token.colorTextLightSolid};
|
||||
padding: 16px 24px;
|
||||
border-radius: 4px;
|
||||
z-index: 2000;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
animation: fadeInOut 2s ease-in-out;
|
||||
@keyframes fadeInOut {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`
|
||||
}));
|
||||
|
||||
export function useEscHint(options?: {
|
||||
enabled?: boolean;
|
||||
message?: string;
|
||||
throttleDelay?: number;
|
||||
}) {
|
||||
const { enabled = true, message, throttleDelay = 3000 } = options || {};
|
||||
const intl = useIntl();
|
||||
const { styles } = useStyles();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const timeoutRef = useRef<any>(null);
|
||||
const isHintActiveRef = useRef(false);
|
||||
|
||||
const showHintThrottled = useMemo(
|
||||
() =>
|
||||
throttle(
|
||||
() => {
|
||||
if (isHintActiveRef.current) return;
|
||||
|
||||
isHintActiveRef.current = true;
|
||||
|
||||
setVisible(true);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setVisible(false);
|
||||
isHintActiveRef.current = false;
|
||||
}, 2000);
|
||||
},
|
||||
throttleDelay,
|
||||
{
|
||||
leading: true,
|
||||
trailing: false
|
||||
}
|
||||
),
|
||||
[throttleDelay]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
HotKeys.ESC,
|
||||
() => {
|
||||
if (!enabled) return;
|
||||
showHintThrottled();
|
||||
},
|
||||
{
|
||||
enabled: enabled
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
showHintThrottled.cancel();
|
||||
};
|
||||
}, [showHintThrottled]);
|
||||
|
||||
const EscHint = visible ? (
|
||||
<div className={styles.hintOverlay}>
|
||||
{message || intl.formatMessage({ id: 'common.tips.escape.disable' })}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return { EscHint };
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import qs from 'query-string';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const createEventSourceURL = (url: string) => {
|
||||
const { host, protocol } = window.location;
|
||||
return `${protocol}://${host}${url}`;
|
||||
};
|
||||
|
||||
/*
|
||||
0: connecting
|
||||
1: connect successfully
|
||||
2: closed
|
||||
*/
|
||||
|
||||
export default function useEventSource() {
|
||||
const eventSourceRef = useRef<any>(null);
|
||||
|
||||
const createEventSourceConnection = (query: {
|
||||
url: string;
|
||||
params: any;
|
||||
onmessage?: (data: any) => void;
|
||||
}) => {
|
||||
eventSourceRef.current?.close?.();
|
||||
|
||||
const { url, params, onmessage = () => {} } = query;
|
||||
|
||||
const sseurl = createEventSourceURL(url);
|
||||
|
||||
eventSourceRef.current = new EventSource(
|
||||
`${url}?${qs.stringify({
|
||||
...params
|
||||
})}`,
|
||||
{
|
||||
withCredentials: true
|
||||
}
|
||||
);
|
||||
|
||||
eventSourceRef.current.onmessage = (res: any) => {
|
||||
try {
|
||||
console.log('event source message: ', { res, resData: res });
|
||||
const data = JSON.parse(res.data);
|
||||
onmessage(data);
|
||||
} catch (error) {
|
||||
// error
|
||||
console.log('event source error: ', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSourceRef.current.onclose = () => {
|
||||
console.log('event source closed...');
|
||||
};
|
||||
|
||||
eventSourceRef.current.onopen = () => {
|
||||
console.log('event source connected...');
|
||||
};
|
||||
|
||||
eventSourceRef.current.onerror = (error: any) => {
|
||||
console.log('event source error: ', error);
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
eventSourceRef.current?.close?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
eventSourceRef: eventSourceRef,
|
||||
createEventSourceConnection
|
||||
};
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { throttle } from 'lodash';
|
||||
import {
|
||||
UseOverlayScrollbarsParams,
|
||||
useOverlayScrollbars
|
||||
} from 'overlayscrollbars-react';
|
||||
import React, { useEffect } from 'react';
|
||||
import useUserSettings from './use-user-settings';
|
||||
|
||||
type OverflowBehavior =
|
||||
| 'hidden'
|
||||
| 'scroll'
|
||||
| 'visible'
|
||||
| 'visible-hidden'
|
||||
| 'visible-scroll';
|
||||
export interface OverlayScrollerOptions {
|
||||
oppositeTheme?: boolean;
|
||||
overflow?: {
|
||||
x?: OverflowBehavior;
|
||||
y?: OverflowBehavior;
|
||||
};
|
||||
scrollbars?: {
|
||||
theme?: 'os-theme-light' | 'os-theme-dark';
|
||||
autoHide?: 'never' | 'scroll' | 'leave' | 'move';
|
||||
autoHideDelay?: number;
|
||||
clickScroll?: boolean | 'instant';
|
||||
};
|
||||
}
|
||||
|
||||
export const overlaySollerOptions: UseOverlayScrollbarsParams = {
|
||||
options: {
|
||||
update: {
|
||||
debounce: 0
|
||||
},
|
||||
overflow: {
|
||||
x: 'hidden'
|
||||
},
|
||||
scrollbars: {
|
||||
theme: 'os-theme-light',
|
||||
autoHide: 'scroll',
|
||||
autoHideDelay: 600,
|
||||
clickScroll: 'instant'
|
||||
}
|
||||
},
|
||||
defer: true
|
||||
};
|
||||
|
||||
const RESETSCROLLDELAY = 5000;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param options.theme: if set theme, it will fix the theme
|
||||
* @returns
|
||||
*/
|
||||
export default function useOverlayScroller(data?: {
|
||||
options?: OverlayScrollerOptions;
|
||||
events?: any;
|
||||
defer?: boolean;
|
||||
}) {
|
||||
const { userSettings } = useUserSettings();
|
||||
const { options, events, defer = true } = data || {};
|
||||
const { scrollbars, overflow, oppositeTheme } = options || {};
|
||||
const scrollEventElement = React.useRef<any>(null);
|
||||
const instanceRef = React.useRef<any>(null);
|
||||
const initialized = React.useRef(false);
|
||||
const scrollElementRef = React.useRef<any>(null);
|
||||
const stopUpdatePosition = React.useRef(false);
|
||||
const timerRef = React.useRef<any>(null);
|
||||
const [initialize, instance] = useOverlayScrollbars({
|
||||
options: {
|
||||
update: {
|
||||
debounce: 0
|
||||
},
|
||||
overflow: {
|
||||
x: 'hidden',
|
||||
...overflow
|
||||
},
|
||||
scrollbars: {
|
||||
autoHide: 'scroll',
|
||||
autoHideDelay: 600,
|
||||
clickScroll: 'instant',
|
||||
...scrollbars,
|
||||
theme:
|
||||
scrollbars?.theme ||
|
||||
(userSettings.theme === 'light' || !userSettings.theme
|
||||
? 'os-theme-dark'
|
||||
: 'os-theme-light')
|
||||
}
|
||||
},
|
||||
events: {
|
||||
...events
|
||||
},
|
||||
defer: defer
|
||||
});
|
||||
|
||||
instanceRef.current = instance?.();
|
||||
scrollEventElement.current =
|
||||
instanceRef.current?.elements()?.scrollEventElement;
|
||||
|
||||
const handleOnScroll = () => {
|
||||
const scrollTop = scrollEventElement.current?.scrollTop;
|
||||
const scrollHeight = scrollEventElement.current?.scrollHeight;
|
||||
const clientHeight = scrollEventElement.current?.clientHeight;
|
||||
|
||||
const isBottom = scrollTop + clientHeight + 20 >= scrollHeight;
|
||||
|
||||
if (isBottom) {
|
||||
stopUpdatePosition.current = false;
|
||||
} else {
|
||||
stopUpdatePosition.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const throttledScroll = useMemoizedFn(
|
||||
throttle(() => {
|
||||
scrollEventElement.current?.scrollTo?.({
|
||||
top: scrollEventElement.current?.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
instanceRef.current?.update?.();
|
||||
}, 100)
|
||||
);
|
||||
|
||||
const scrollauto = useMemoizedFn(() => {
|
||||
scrollEventElement.current?.scrollTo?.({
|
||||
top: scrollEventElement.current.scrollHeight,
|
||||
behavior: 'auto'
|
||||
});
|
||||
instanceRef.current?.update?.();
|
||||
});
|
||||
|
||||
// scroll to bottom
|
||||
const throttledUpdateScrollerPosition = useMemoizedFn((delay?: number) => {
|
||||
if (stopUpdatePosition.current) {
|
||||
return;
|
||||
}
|
||||
if (delay === 0) {
|
||||
scrollauto();
|
||||
} else {
|
||||
throttledScroll();
|
||||
}
|
||||
});
|
||||
|
||||
// scroll to top
|
||||
const updateScrollerPositionToTop = useMemoizedFn(() => {
|
||||
scrollEventElement.current?.scrollTo?.({
|
||||
top: 0,
|
||||
behavior: 'auto'
|
||||
});
|
||||
instanceRef.current?.update?.();
|
||||
});
|
||||
|
||||
const generateInstance = () => {
|
||||
instanceRef.current = instance?.();
|
||||
scrollEventElement.current =
|
||||
instanceRef.current?.elements()?.scrollEventElement;
|
||||
};
|
||||
|
||||
const handleWheelCallback = useMemoizedFn((e: any) => {
|
||||
handleOnScroll();
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
stopUpdatePosition.current = false;
|
||||
}, RESETSCROLLDELAY);
|
||||
});
|
||||
|
||||
// add wheel event
|
||||
const handleWheelEvent = () => {
|
||||
scrollElementRef.current?.addEventListener?.('wheel', handleWheelCallback, {
|
||||
passive: true
|
||||
});
|
||||
};
|
||||
|
||||
// remove wheel event
|
||||
const removeWheelEvent = () => {
|
||||
scrollElementRef.current?.removeEventListener?.(
|
||||
'wheel',
|
||||
handleWheelCallback,
|
||||
{ passive: true }
|
||||
);
|
||||
};
|
||||
|
||||
const createInstance = useMemoizedFn((el: any) => {
|
||||
if (instanceRef.current) {
|
||||
return instanceRef.current;
|
||||
}
|
||||
if (el) {
|
||||
initialize(el);
|
||||
scrollElementRef.current = el;
|
||||
initialized.current = true;
|
||||
instanceRef.current = instance?.();
|
||||
scrollEventElement.current =
|
||||
instanceRef.current?.elements()?.scrollEventElement;
|
||||
handleWheelEvent();
|
||||
}
|
||||
return instanceRef.current;
|
||||
});
|
||||
|
||||
const destroyInstance = () => {
|
||||
instanceRef.current?.destroy?.();
|
||||
removeWheelEvent();
|
||||
instanceRef.current = null;
|
||||
};
|
||||
|
||||
const scrollToTarget = (target: any, offset = 100) => {
|
||||
if (!target) return;
|
||||
if (!instanceRef.current || !scrollEventElement.current) {
|
||||
instanceRef.current = instance?.();
|
||||
scrollEventElement.current =
|
||||
instanceRef.current?.elements()?.scrollEventElement;
|
||||
}
|
||||
|
||||
const viewport = instanceRef.current?.elements().viewport;
|
||||
const containerRect = viewport.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
|
||||
const scrollerState = instanceRef.current?.state();
|
||||
|
||||
const currentScroll = scrollerState.current?.overflowAmount?.y;
|
||||
|
||||
// const currentScroll = instanceRef.current?.scroll().position.y;
|
||||
const targetPos = targetRect.top - containerRect.top + currentScroll;
|
||||
|
||||
scrollEventElement.current.scroll({
|
||||
y: targetPos - offset,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
instanceRef.current?.update?.();
|
||||
};
|
||||
|
||||
const getScrollElementScrollableHeight = () => {
|
||||
if (!instanceRef.current || !scrollEventElement.current) {
|
||||
instanceRef.current = instance?.();
|
||||
scrollEventElement.current =
|
||||
instanceRef.current?.elements()?.scrollEventElement;
|
||||
}
|
||||
|
||||
const scrollOffsetElement = instanceRef.current?.elements().viewport;
|
||||
|
||||
return {
|
||||
scrollTop: scrollOffsetElement?.scrollTop,
|
||||
scrollHeight:
|
||||
scrollOffsetElement?.scrollHeight - scrollOffsetElement?.clientHeight
|
||||
};
|
||||
};
|
||||
|
||||
const getScrollElement = () => {
|
||||
if (!instanceRef.current || !scrollEventElement.current) {
|
||||
instanceRef.current = instance?.();
|
||||
scrollEventElement.current =
|
||||
instanceRef.current?.elements()?.scrollEventElement;
|
||||
}
|
||||
return scrollEventElement;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
instanceRef.current?.destroy?.();
|
||||
removeWheelEvent();
|
||||
};
|
||||
}, [instance]);
|
||||
|
||||
return {
|
||||
initialize: createInstance,
|
||||
instance: instanceRef,
|
||||
scrollEventElement: scrollEventElement,
|
||||
initialized: initialized.current,
|
||||
getScrollElementScrollableHeight,
|
||||
getScrollElement,
|
||||
generateInstance,
|
||||
destroyInstance: destroyInstance,
|
||||
updateScrollerPosition: throttledUpdateScrollerPosition,
|
||||
updateScrollerPositionToTop: updateScrollerPositionToTop,
|
||||
scrollToBottom: scrollauto,
|
||||
scrollToTop: updateScrollerPositionToTop,
|
||||
scrollToTarget
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,11 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
|
||||
}
|
||||
});
|
||||
|
||||
const cancelWatch = useMemoizedFn(() => {
|
||||
chunkRequestRef.current?.current?.cancel?.();
|
||||
listRequestTokenRef.current?.cancel?.();
|
||||
});
|
||||
|
||||
const queryAllDataList = async (
|
||||
params: Global.SearchParams,
|
||||
options?: any
|
||||
@@ -78,14 +83,15 @@ export default function useWatchList<T = Record<string, any>>(API: string) {
|
||||
useEffect(() => {
|
||||
createWatchChunkRequest();
|
||||
return () => {
|
||||
chunkRequestRef.current?.cancel?.();
|
||||
listRequestTokenRef.current?.cancel?.();
|
||||
cancelWatch();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
watchDataList,
|
||||
setWatchDataList,
|
||||
startWatch: createWatchChunkRequest,
|
||||
cancelWatch,
|
||||
deleteItemFromCache: handleDeleteItemFromCache
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user';
|
||||
import PluginExtraField from '@/components/plugin-extra-fields';
|
||||
import VersionInfo, { modalConfig } from '@/components/version-info';
|
||||
import externalLinks from '@/constants/external-links';
|
||||
import useBodyScroll from '@/hooks/use-body-scroll';
|
||||
import { logout } from '@/pages/login/apis';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { useModel } from '@@/plugin-model';
|
||||
import {
|
||||
DiscordOutlined,
|
||||
@@ -13,12 +12,11 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { DropdownActions, IconFont } from '@gpustack/core-ui';
|
||||
import { history, useIntl, useNavigate } from '@umijs/max';
|
||||
import { Avatar, Button, Divider, Modal } from 'antd';
|
||||
import { Avatar, Divider } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { DEFAULT_ENTER_PAGE } from '../config/settings';
|
||||
import GithubStar from './github-star';
|
||||
|
||||
const NewLabel = styled.span`
|
||||
position: relative;
|
||||
@@ -98,11 +96,10 @@ const CustomItem = styled.div`
|
||||
|
||||
export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
|
||||
const { isDarkTheme } = props;
|
||||
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const plugin = getGPUStackPlugin();
|
||||
const intl = useIntl();
|
||||
const [version] = useAtom(GPUStackVersionAtom);
|
||||
const [updateCheck] = useAtom(UpdateCheckAtom);
|
||||
const intl = useIntl();
|
||||
const initialInfo = useModel('@@initialState') || {
|
||||
initialState: undefined,
|
||||
loading: false,
|
||||
@@ -140,16 +137,6 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
|
||||
return {};
|
||||
}, [isDarkTheme]);
|
||||
|
||||
const showVersion = () => {
|
||||
saveScrollHeight();
|
||||
modal.info({
|
||||
...modalConfig,
|
||||
width: 460,
|
||||
content: <VersionInfo intl={intl} />,
|
||||
onCancel: restoreScrollHeight
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate(loginPath);
|
||||
@@ -159,7 +146,7 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
|
||||
{
|
||||
key: 'site',
|
||||
icon: <HomeOutlined />,
|
||||
label: 'GPUStack',
|
||||
label: 'ZStack AIOS',
|
||||
url: externalLinks.site
|
||||
},
|
||||
{
|
||||
@@ -259,25 +246,20 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
{contextHolder}
|
||||
<PluginExtraField name="OrgSwitcher" isDarkTheme={isDarkTheme} />
|
||||
{process.env.ENABLE_ENTERPRISE !== 'true' && <GithubStar />}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={showVersion}
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--ant-color-text-tertiary)'
|
||||
}}
|
||||
>
|
||||
{version.version}
|
||||
</Button>
|
||||
</span>
|
||||
{showUpgrade && (
|
||||
<NewLabel>
|
||||
<span className="text">
|
||||
@@ -286,15 +268,17 @@ export const ExtraContent = (props: { isDarkTheme?: boolean }) => {
|
||||
</NewLabel>
|
||||
)}
|
||||
</div>
|
||||
<DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}>
|
||||
<IconWrapper>
|
||||
<IconFont
|
||||
type="icon-help"
|
||||
className="font-size-20"
|
||||
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
||||
/>
|
||||
</IconWrapper>
|
||||
</DropdownActions>
|
||||
{!plugin && (
|
||||
<DropdownActions menu={{ ...helpMenu }} popupRender={helpPopupRender}>
|
||||
<IconWrapper>
|
||||
<IconFont
|
||||
type="icon-help"
|
||||
className="font-size-20"
|
||||
style={{ color: 'var(--ant-color-text-tertiary)' }}
|
||||
/>
|
||||
</IconWrapper>
|
||||
</DropdownActions>
|
||||
)}
|
||||
<PluginExtraField name="GlobalSettings" />
|
||||
<DropdownActions menu={{ ...userMenu }} popupRender={userPopupRender}>
|
||||
<IconWrapper>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import externalLinks from '@/constants/external-links';
|
||||
import { GithubFilled } from '@ant-design/icons';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Tooltip } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const REPO = 'gpustack/gpustack';
|
||||
const CACHE_KEY = 'gpustack:github-stars';
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
const FETCH_TIMEOUT = 4000;
|
||||
|
||||
const StarLink = styled.a`
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
height: 24px;
|
||||
border-radius: var(--ant-border-radius);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
background-color: var(--ant-color-bg-container);
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ant-color-border);
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.seg + .seg {
|
||||
border-left: 1px solid var(--ant-color-border-secondary);
|
||||
background-color: var(--ant-color-fill-quaternary);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const formatCount = (n: number): string => {
|
||||
if (n >= 1000) {
|
||||
const k = n / 1000;
|
||||
return k >= 10 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
|
||||
}
|
||||
return String(n);
|
||||
};
|
||||
|
||||
type CacheEntry = { value: number; time: number };
|
||||
|
||||
const readCache = (): CacheEntry | null => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed?.value !== 'number' || typeof parsed?.time !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeCache = (value: number) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({ value, time: Date.now() })
|
||||
);
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
};
|
||||
|
||||
const GithubStar = () => {
|
||||
const intl = useIntl();
|
||||
const [count, setCount] = useState<number | null>(
|
||||
() => readCache()?.value ?? null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = readCache();
|
||||
const fresh = cached && Date.now() - cached.time < CACHE_TTL;
|
||||
if (fresh) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
||||
|
||||
fetch(`https://api.github.com/repos/${REPO}`, { signal: controller.signal })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (!data || typeof data.stargazers_count !== 'number') return;
|
||||
setCount(data.stargazers_count);
|
||||
writeCache(data.stargazers_count);
|
||||
})
|
||||
.catch(() => {
|
||||
// offline, blocked, rate-limited — stay hidden if no cache
|
||||
})
|
||||
.finally(() => clearTimeout(timer));
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tooltip title={intl.formatMessage({ id: 'common.github.star.tooltip' })}>
|
||||
<StarLink href={externalLinks.github} target="_blank" rel="noreferrer">
|
||||
<span className="seg">
|
||||
<GithubFilled />
|
||||
</span>
|
||||
<span className="seg">
|
||||
<span className="count">
|
||||
{count != null ? formatCount(count) : 'Star'}
|
||||
</span>
|
||||
</span>
|
||||
</StarLink>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default GithubStar;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { IconFont } from '@gpustack/core-ui';
|
||||
import { Link, useLocation, useNavigate } from '@umijs/max';
|
||||
import { Menu } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface MenuItem {
|
||||
icon?: string;
|
||||
selectedIcon?: string;
|
||||
defaultIcon?: string;
|
||||
children?: MenuItem[];
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface HeaderMenuProps {
|
||||
menuData: MenuItem[];
|
||||
initialState?: Global.InitialStateType;
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ css }) => {
|
||||
return {
|
||||
headerMenu: css`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
line-height: inherit;
|
||||
|
||||
&.ant-menu-horizontal {
|
||||
border-bottom: none;
|
||||
}
|
||||
&.ant-menu-horizontal > .ant-menu-item::after,
|
||||
&.ant-menu-horizontal > .ant-menu-submenu::after {
|
||||
display: none;
|
||||
}
|
||||
.ant-menu-title-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
`
|
||||
};
|
||||
});
|
||||
|
||||
const isItemSelected = (item: MenuItem, pathname: string) => {
|
||||
return (
|
||||
pathname === item.path ||
|
||||
(Array.isArray(item.subMenu) && item.subMenu.includes(pathname))
|
||||
);
|
||||
};
|
||||
|
||||
const HeaderMenu: React.FC<HeaderMenuProps> = (props) => {
|
||||
const { menuData } = props;
|
||||
const { styles } = useStyles();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const buildLeaf = (item: MenuItem) => {
|
||||
const selected = isItemSelected(item, location.pathname);
|
||||
return {
|
||||
key: item.path as string,
|
||||
label: (
|
||||
<Link
|
||||
prefetch="intent"
|
||||
to={(item.path as string).replace('/*', '')}
|
||||
target={item.target}
|
||||
>
|
||||
<span className="flex-center gap-8">
|
||||
<IconFont
|
||||
type={selected ? item.selectedIcon || '' : item.defaultIcon || ''}
|
||||
/>
|
||||
<span>{item.name}</span>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
const items = useMemo(() => {
|
||||
return menuData.map((item) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
return {
|
||||
key: item.key,
|
||||
label: item.name,
|
||||
children: item.children.map((child) => buildLeaf(child))
|
||||
};
|
||||
}
|
||||
return buildLeaf(item);
|
||||
});
|
||||
}, [menuData, location.pathname]);
|
||||
|
||||
const selectedKeys = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
for (const item of menuData) {
|
||||
const leaves =
|
||||
item.children && item.children.length > 0 ? item.children : [item];
|
||||
for (const leaf of leaves) {
|
||||
if (isItemSelected(leaf, location.pathname)) {
|
||||
keys.push(leaf.path as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}, [menuData, location.pathname]);
|
||||
|
||||
const handleClick = ({ key }: { key: string }) => {
|
||||
if (key.startsWith('/')) {
|
||||
navigate(key.replace('/*', ''));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={styles.headerMenu}
|
||||
mode="horizontal"
|
||||
selectedKeys={selectedKeys}
|
||||
items={items}
|
||||
onClick={handleClick}
|
||||
triggerSubMenuAction="hover"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderMenu;
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { useAccessMarkedRoutes } from '@@/plugin-access';
|
||||
import { useModel } from '@@/plugin-model';
|
||||
import { ProLayout } from '@ant-design/pro-components';
|
||||
import { CoreUIProvider, IconFont } from '@gpustack/core-ui';
|
||||
import { CoreUIProvider } from '@gpustack/core-ui';
|
||||
import {
|
||||
Access,
|
||||
Outlet,
|
||||
@@ -39,33 +39,18 @@ import {
|
||||
useNavigate,
|
||||
type IRoute
|
||||
} from '@umijs/max';
|
||||
import { Button, ConfigProvider, Modal, theme } from 'antd';
|
||||
import { ConfigProvider, Modal, theme } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { PageContainerInner } from '../pages/_components/page-box';
|
||||
import Exception from './Exception';
|
||||
import './Layout.css';
|
||||
import { LogoIcon, SLogoIcon } from './Logo';
|
||||
import { LogoIcon } from './Logo';
|
||||
import ErrorBoundary from './error-boundary';
|
||||
import { ExtraContent } from './extraRender';
|
||||
import HeaderMenu from './header-menu';
|
||||
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',
|
||||
@@ -133,7 +118,7 @@ const mapRoutes = (routes: IRoute[], role: string) => {
|
||||
|
||||
export default (props: any) => {
|
||||
const [, contextHolder] = Modal.useModal();
|
||||
const { themeData, setUserSettings, userSettings } = useUserSettings();
|
||||
const { themeData, userSettings } = useUserSettings();
|
||||
const [userInfo] = useAtom(userAtom);
|
||||
const [routeCache] = useAtom(routeCacheAtom);
|
||||
const location = useLocation();
|
||||
@@ -238,13 +223,6 @@ export default (props: any) => {
|
||||
|
||||
const coreUISlots = useMemo(() => ({ ExtraContent }), []);
|
||||
|
||||
const handleToggleCollapse = (e: any) => {
|
||||
e.stopPropagation();
|
||||
setUserSettings({
|
||||
...userSettings,
|
||||
collapsed: !userSettings.collapsed
|
||||
});
|
||||
};
|
||||
const newRoutes = filterRoutes(
|
||||
// @ts-ignore
|
||||
clientRoutes.filter((route) => route.id === 'max-tabs'),
|
||||
@@ -269,21 +247,19 @@ 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]);
|
||||
|
||||
const renderMenuHeader = (logo: React.ReactNode, title: React.ReactNode) => {
|
||||
return <>{logo}</>;
|
||||
};
|
||||
|
||||
const menuContentRender = (menuProps: any, defaultDom: React.ReactNode) => {
|
||||
return <SiderMenu {...menuProps}></SiderMenu>;
|
||||
const headerContentRender = (
|
||||
headerProps: any,
|
||||
defaultDom: React.ReactNode
|
||||
) => {
|
||||
return <HeaderMenu {...headerProps}></HeaderMenu>;
|
||||
};
|
||||
|
||||
const actionsRender = () => {
|
||||
return <ExtraContent isDarkTheme={userSettings.isDarkTheme} />;
|
||||
};
|
||||
|
||||
const onPageChange = async (route: any) => {
|
||||
@@ -337,17 +313,6 @@ export default (props: any) => {
|
||||
navigate(pagepath);
|
||||
};
|
||||
|
||||
const onCollapse = (value: boolean) => {
|
||||
// only trigger by window resize
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setUserSettings({
|
||||
...userSettings,
|
||||
collapsed: value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
componentSize="large"
|
||||
@@ -408,48 +373,27 @@ export default (props: any) => {
|
||||
<DarkMask></DarkMask>
|
||||
<ProLayout
|
||||
fixSiderbar
|
||||
fixedHeader={false}
|
||||
headerRender={false}
|
||||
fixedHeader
|
||||
breadcrumbRender={false}
|
||||
route={route}
|
||||
location={location}
|
||||
title={userConfig.title}
|
||||
navTheme={userSettings.theme}
|
||||
layout="side"
|
||||
layout="top"
|
||||
contentStyle={{
|
||||
paddingBlock: 0,
|
||||
paddingInline: 0
|
||||
}}
|
||||
openKeys={false}
|
||||
disableMobile={true}
|
||||
siderWidth={220}
|
||||
menuFooterRender={() => (
|
||||
<Button
|
||||
style={{
|
||||
border: 'none'
|
||||
}}
|
||||
size="small"
|
||||
type={'text'}
|
||||
onClick={handleToggleCollapse}
|
||||
>
|
||||
<IconFont
|
||||
type={collapsed ? 'icon-expand-left' : 'icon-expand-right'}
|
||||
className="font-size-18"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
onCollapse={onCollapse}
|
||||
onMenuHeaderClick={onMenuHeaderClick}
|
||||
collapsed={userSettings.collapsed}
|
||||
onPageChange={onPageChange}
|
||||
formatMessage={formatMessage}
|
||||
menu={{
|
||||
locale: true,
|
||||
type: 'group'
|
||||
locale: true
|
||||
}}
|
||||
splitMenus={true}
|
||||
logo={userSettings.collapsed ? <SLogoIcon /> : <LogoIcon />}
|
||||
menuContentRender={menuContentRender}
|
||||
logo={<LogoIcon />}
|
||||
headerContentRender={headerContentRender}
|
||||
actionsRender={actionsRender}
|
||||
{...runtimeConfig}
|
||||
ErrorBoundary={ErrorBoundary}
|
||||
>
|
||||
@@ -457,7 +401,7 @@ export default (props: any) => {
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
height: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
@@ -469,15 +413,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}
|
||||
|
||||