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 | ||
|
|
ee42a9d0ed | ||
|
|
28976dea4b | ||
|
|
22e5cc3aa5 | ||
|
|
3052c0b23e | ||
|
|
1c9cabf7e7 | ||
|
|
03842d1741 | ||
|
|
84304eadb7 | ||
|
|
ab3739219e | ||
|
|
7cf4932f3a | ||
|
|
032f1eefef | ||
|
|
e35be94f7c | ||
|
|
e76e2ad85a | ||
|
|
cb19cd2476 | ||
|
|
c532732ce9 | ||
|
|
2bc10dbb2e | ||
|
|
b634a03ef9 | ||
|
|
ac64bb98ad | ||
|
|
aa49539210 | ||
|
|
1f5678f781 | ||
|
|
7adc089e94 | ||
|
|
6e1e9c07ee | ||
|
|
1d55382f3b | ||
|
|
e68bb0bc4e | ||
|
|
c8e14dade5 | ||
|
|
e4c1a3f5c2 | ||
|
|
cdd6ef9cb3 | ||
|
|
737d43fd16 | ||
|
|
9f5163531c | ||
|
|
df5cf0f4e5 | ||
|
|
e160b64697 | ||
|
|
7d36c6ac4a | ||
|
|
0820edf55f | ||
|
|
12e324893f | ||
|
|
4017c4dd33 | ||
|
|
17b65d80e1 | ||
|
|
47cb3321ab | ||
|
|
547b9f617b | ||
|
|
9e7bd78693 | ||
|
|
c53d60b628 | ||
|
|
9566e0cdb2 | ||
|
|
ed3e779d22 | ||
|
|
3e2133753d | ||
|
|
736a853192 | ||
|
|
08ef2ffa9a | ||
|
|
6cf512cccd | ||
|
|
6f56e614f3 | ||
|
|
8c06862705 | ||
|
|
48159171c2 | ||
|
|
d5d0749b72 | ||
|
|
dc4dd81e11 | ||
|
|
c736e2186d | ||
|
|
7a76bda484 | ||
|
|
778ea36a9d | ||
|
|
04e1e1e566 | ||
|
|
01f66085ab | ||
|
|
ddf723525a | ||
|
|
7b18a92388 | ||
|
|
b985dcee63 | ||
|
|
77e92432ed | ||
|
|
a7b33a928b | ||
|
|
1218c6d096 | ||
|
|
6337431ed4 | ||
|
|
2ba5118bc2 | ||
|
|
12649c7ccf | ||
|
|
5d96932870 | ||
|
|
e6cfa6cf77 | ||
|
|
55d16c5681 | ||
|
|
ab75685955 | ||
|
|
bd0bf0bf63 | ||
|
|
fd050b33d9 | ||
|
|
698090b17a | ||
|
|
6f96260993 | ||
|
|
98a6b19298 | ||
|
|
ded270ff25 | ||
|
|
c8860207e8 | ||
|
|
8d045ffd8b | ||
|
|
fd7437e74a | ||
|
|
807ba866d3 | ||
|
|
5e2808d7c4 | ||
|
|
a26a3ff8f9 | ||
|
|
a2e9f69b96 | ||
|
|
ac800a303a | ||
|
|
dd542a9f29 | ||
|
|
26ce456bde | ||
|
|
7ce1faaf6c | ||
|
|
878bdaa5a7 | ||
|
|
68a7e3f66a | ||
|
|
feccc81c5f | ||
|
|
620045d7a3 | ||
|
|
6dc28d61b0 | ||
|
|
754cc29793 | ||
|
|
ed525509ea | ||
|
|
2b6f26e6c8 | ||
|
|
09821aab85 | ||
|
|
636190b8e6 | ||
|
|
e420c22a45 | ||
|
|
993f6edcc4 | ||
|
|
bda8264788 | ||
|
|
ab8e792276 | ||
|
|
0e4be63b6b | ||
|
|
d3ebea9090 | ||
|
|
15a9e23243 | ||
|
|
eab7d9c77f | ||
|
|
4a86b71018 | ||
|
|
6350f7f11a | ||
|
|
ce6388253c | ||
|
|
d8f6064d5e | ||
|
|
9f5d95f565 | ||
|
|
0cabbeb81c | ||
|
|
76618aa3a1 | ||
|
|
b19db0f67d | ||
|
|
76f228bfe7 | ||
|
|
be40692773 | ||
|
|
42ac377c17 | ||
|
|
46f859cc3c | ||
|
|
f2bff0d1fb | ||
|
|
0bd98b9662 | ||
|
|
1ac2c02197 | ||
|
|
907187d53a | ||
|
|
faf6246fe6 | ||
|
|
0c16e2be7a | ||
|
|
3212882895 | ||
|
|
62f34ffcaf | ||
|
|
50601b0aff | ||
|
|
4c5d42cb13 | ||
|
|
85268b8917 | ||
|
|
973d2c4529 | ||
|
|
ab766b8c54 | ||
|
|
4945242c87 | ||
|
|
c4a72eb375 | ||
|
|
b12d11728d | ||
|
|
8f0e3a7576 | ||
|
|
4810ff2dbe | ||
|
|
a221b22be9 | ||
|
|
24b458530d | ||
|
|
caea43590a | ||
|
|
c3d7a0b85e | ||
|
|
5c6f28027d | ||
|
|
dc04529f5b | ||
|
|
2bb04a419d | ||
|
|
8e31b71668 | ||
|
|
f055453889 | ||
|
|
820a6cecdd | ||
|
|
c3a779e4c8 | ||
|
|
00008abf03 | ||
|
|
d9c6f2f780 | ||
|
|
9a4d77e869 | ||
|
|
2407416e33 | ||
|
|
234e42ccfa | ||
|
|
4426b8dc30 | ||
|
|
9541f49f39 | ||
|
|
9f7a99f370 | ||
|
|
12f0bb2f98 | ||
|
|
ac45a72e0e | ||
|
|
9705818b15 | ||
|
|
39374eafda | ||
|
|
110bc3205e | ||
|
|
57fec89f2d | ||
|
|
72e794c281 | ||
|
|
fadbcc3e44 | ||
|
|
9e30e964e6 | ||
|
|
fbb707d014 | ||
|
|
d2d82ee5ee | ||
|
|
9061fa08ba | ||
|
|
f29236518e | ||
|
|
6537de5224 | ||
|
|
0413d470a8 | ||
|
|
c01f4d3ce8 | ||
|
|
dd9d359bb0 | ||
|
|
b444c35643 | ||
|
|
abd0d52fad | ||
|
|
c8771dd3b9 | ||
|
|
6d16f56183 | ||
|
|
95ffe0350e | ||
|
|
e314f25a77 | ||
|
|
65ea65e9ae | ||
|
|
4ce975562e | ||
|
|
07e625f2bc | ||
|
|
d95fd8dc90 | ||
|
|
55028cf3a3 | ||
|
|
16a97772d3 | ||
|
|
d33167d070 | ||
|
|
f62e0ba372 | ||
|
|
1b6466ae78 | ||
|
|
29c5987bf1 | ||
|
|
fcd1d01e83 | ||
|
|
a80b889760 | ||
|
|
f71bf1b074 | ||
|
|
7e048ee537 | ||
|
|
34098e8950 | ||
|
|
faf0d4d605 | ||
|
|
85ade88d55 | ||
|
|
7d0b94330d | ||
|
|
5cff1298fc | ||
|
|
d3a3d4e96e | ||
|
|
f99621ec92 | ||
|
|
e47a9304f4 | ||
|
|
9488fc1251 | ||
|
|
b590aa746b | ||
|
|
ef61134b8b | ||
|
|
a9daa0b586 | ||
|
|
ae0ad25ed6 | ||
|
|
a4c48c1981 | ||
|
|
3497590d2e | ||
|
|
11cf1329c8 | ||
|
|
ba5b41cf3a | ||
|
|
1d70446cc4 | ||
|
|
f15d0ab243 | ||
|
|
34be4800d7 | ||
|
|
e40b0c2e07 | ||
|
|
45e0ad9e93 | ||
|
|
96e6ddfe8a | ||
|
|
6b47bacfac | ||
|
|
099b419e34 | ||
|
|
e343ec0a1e | ||
|
|
e3d6d08916 | ||
|
|
d0bc206c83 | ||
|
|
7287f8c81b | ||
|
|
1878753cde | ||
|
|
2f04e01f4f | ||
|
|
a51f44c135 | ||
|
|
7f14eb544c | ||
|
|
c6e34db329 | ||
|
|
acb90531b2 | ||
|
|
91c3d0b814 | ||
|
|
95e03d11c1 | ||
|
|
88ab927755 | ||
|
|
81c7e2ee61 | ||
|
|
b5ed64de51 | ||
|
|
921a2a0d81 | ||
|
|
5195b1e5fc | ||
|
|
f886422f91 | ||
|
|
27d57d7563 | ||
|
|
2ebb8f44b7 | ||
|
|
a0b408e8cb | ||
|
|
858e0dae09 | ||
|
|
7bee337554 | ||
|
|
7944b3f579 | ||
|
|
5e889563ca | ||
|
|
aad3b458c8 | ||
|
|
acef2ef89a | ||
|
|
d99c25dd03 | ||
|
|
f0a1dad48e | ||
|
|
1e334903dd | ||
|
|
342583f8d1 | ||
|
|
6af18275c4 | ||
|
|
9c519d937d | ||
|
|
4b14fa6ca8 | ||
|
|
dc69e51042 | ||
|
|
052d5aa474 | ||
|
|
0c55c1c1b4 | ||
|
|
6f896b4f94 | ||
|
|
e7a376db70 | ||
|
|
15cf4d89b7 | ||
|
|
c38fcc494a | ||
|
|
44e5b78d29 | ||
|
|
38107a67fe | ||
|
|
0274bcb8cd | ||
|
|
a91c1545f8 | ||
|
|
0b3884b1a5 | ||
|
|
bcea46fa8f | ||
|
|
abc3c2c25a | ||
|
|
db7a8a702d | ||
|
|
288bfebc13 | ||
|
|
c7996d3e14 | ||
|
|
0d9b6325f9 | ||
|
|
baf1508644 | ||
|
|
d04604abe5 | ||
|
|
bf09784fad | ||
|
|
67f5c794e1 | ||
|
|
55fbe118d5 | ||
|
|
ae8ec8572c | ||
|
|
38b91a558b | ||
|
|
a001f83abb | ||
|
|
87c8a3a64d | ||
|
|
c6e7075bf5 | ||
|
|
5d75cfdfc5 | ||
|
|
3f9852c932 | ||
|
|
9dfaf990fd | ||
|
|
1bef1637e0 | ||
|
|
451d5bfe47 | ||
|
|
faa1f935b7 | ||
|
|
c428fe175a | ||
|
|
9767c8bf39 | ||
|
|
96b50538ed |
@@ -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,5 +13,6 @@
|
||||
.swc
|
||||
.DS_Store
|
||||
.idea
|
||||
.claude
|
||||
.claude/settings.local.json
|
||||
/dist.zip
|
||||
.cache
|
||||
@@ -3,7 +3,6 @@ node_modules
|
||||
.umi-production
|
||||
public/static/*.js
|
||||
public/static/*.css
|
||||
src/components/icon-font/iconfont/iconfont.js
|
||||
src/components/icon-font/iconfont/*.css
|
||||
src/components/iconfont/
|
||||
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@ module.exports = {
|
||||
rules: {
|
||||
'selector-class-pattern': null
|
||||
},
|
||||
ignoreFiles: ['public/static/*.css']
|
||||
ignoreFiles: ['public/static/*.css', 'src/components/iconfont/iconfont.css']
|
||||
};
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -31,5 +31,6 @@ export default function createProxyTable(target?: string) {
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return proxyTable;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -140,15 +139,6 @@ const baseRoutes = [
|
||||
access: 'canSeeOrgAdmin',
|
||||
component: './model-routes/index'
|
||||
},
|
||||
{
|
||||
name: 'usage',
|
||||
path: '/models/usage',
|
||||
key: 'usage',
|
||||
icon: 'icon-usage-outlined',
|
||||
selectedIcon: 'icon-usage-filled',
|
||||
defaultIcon: 'icon-usage-outlined',
|
||||
component: './usage/index'
|
||||
},
|
||||
{
|
||||
name: 'providers',
|
||||
path: '/models/providers',
|
||||
@@ -179,6 +169,26 @@ const baseRoutes = [
|
||||
access: 'canSeeOrgAdmin',
|
||||
hideInMenu: true,
|
||||
component: './benchmark/details'
|
||||
},
|
||||
{
|
||||
name: 'backendsList',
|
||||
path: '/models/backends',
|
||||
key: 'backendsList',
|
||||
icon: 'icon-backend',
|
||||
selectedIcon: 'icon-backend-filled',
|
||||
defaultIcon: 'icon-backend',
|
||||
access: 'canSeeOrgAdmin',
|
||||
component: './backends/index'
|
||||
},
|
||||
{
|
||||
name: 'modelfiles',
|
||||
path: '/models/modelfiles',
|
||||
key: 'modelfiles',
|
||||
icon: 'icon-files',
|
||||
selectedIcon: 'icon-files-filled',
|
||||
defaultIcon: 'icon-files',
|
||||
access: 'canSeeOrgAdmin',
|
||||
component: './resources/components/model-files'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -186,6 +196,7 @@ const baseRoutes = [
|
||||
name: 'gpuService',
|
||||
path: '/gpu-service',
|
||||
key: 'gpuService',
|
||||
access: 'canSeeGpuService',
|
||||
routes: [
|
||||
{
|
||||
path: '/gpu-service',
|
||||
@@ -213,10 +224,25 @@ const baseRoutes = [
|
||||
name: 'storage',
|
||||
path: '/gpu-service/storage',
|
||||
key: 'gpuServiceStorage',
|
||||
icon: 'icon-database-outlined',
|
||||
selectedIcon: 'icon-database-filled',
|
||||
defaultIcon: 'icon-database-outlined',
|
||||
component: './gpu-service/storage'
|
||||
},
|
||||
{
|
||||
name: 'storageTypes',
|
||||
path: '/gpu-service/storage-types',
|
||||
key: 'gpuServiceStorageTypes',
|
||||
icon: 'icon-storage-outlined',
|
||||
// Storage types are tenant-scoped on the backend (Org owners
|
||||
// can create/list their own), so the menu shouldn't be
|
||||
// platform-admin-only. ``canSeeOrgAdmin`` keeps the gate at
|
||||
// "admin or current-org owner" — Org members still don't see
|
||||
// it, which matches the read/write model in the route.
|
||||
access: 'canSeeOrgAdmin',
|
||||
selectedIcon: 'icon-storage-filled',
|
||||
defaultIcon: 'icon-storage-outlined',
|
||||
component: './gpu-service/storage'
|
||||
component: './gpu-service/storage-types'
|
||||
},
|
||||
{
|
||||
name: 'publicKeys',
|
||||
@@ -239,6 +265,16 @@ const baseRoutes = [
|
||||
path: '/resources',
|
||||
redirect: '/resources/workers'
|
||||
},
|
||||
{
|
||||
name: 'clusters',
|
||||
path: '/resources/clusters/list',
|
||||
key: 'clusters',
|
||||
icon: 'icon-cluster2-outline',
|
||||
selectedIcon: 'icon-cluster2-filled',
|
||||
defaultIcon: 'icon-cluster2-outline',
|
||||
component: './cluster-management/clusters',
|
||||
subMenu: ['/resources/clusters/create']
|
||||
},
|
||||
{
|
||||
name: 'workers',
|
||||
path: '/resources/workers',
|
||||
@@ -257,63 +293,9 @@ const baseRoutes = [
|
||||
defaultIcon: 'icon-gpu1',
|
||||
component: './resources/components/gpus'
|
||||
},
|
||||
{
|
||||
name: 'backendsList',
|
||||
path: '/resources/backends',
|
||||
key: 'backendsList',
|
||||
icon: 'icon-backend',
|
||||
selectedIcon: 'icon-backend-filled',
|
||||
defaultIcon: 'icon-backend',
|
||||
access: 'canSeeOrgAdmin',
|
||||
component: './backends/index'
|
||||
},
|
||||
{
|
||||
name: 'modelfiles',
|
||||
path: '/resources/modelfiles',
|
||||
key: 'modelfiles',
|
||||
icon: 'icon-files',
|
||||
selectedIcon: 'icon-files-filled',
|
||||
defaultIcon: 'icon-files',
|
||||
component: './resources/components/model-files'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'clusterManagement',
|
||||
path: '/cluster-management',
|
||||
key: 'clusterManagement',
|
||||
access: 'canSeeOrgAdmin',
|
||||
routes: [
|
||||
{
|
||||
path: '/cluster-management',
|
||||
redirect: '/cluster-management/clusters/list'
|
||||
},
|
||||
{
|
||||
name: 'clusters',
|
||||
path: '/cluster-management/clusters/list',
|
||||
key: 'clusters',
|
||||
icon: 'icon-cluster2-outline',
|
||||
selectedIcon: 'icon-cluster2-filled',
|
||||
defaultIcon: 'icon-cluster2-outline',
|
||||
component: './cluster-management/clusters',
|
||||
subMenu: [
|
||||
'/cluster-management/clusters/detail',
|
||||
'/cluster-management/clusters/create'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'clusterDetail',
|
||||
path: '/cluster-management/clusters/detail',
|
||||
key: 'clusterDetail',
|
||||
icon: 'icon-cluster2-outline',
|
||||
selectedIcon: 'icon-cluster2-filled',
|
||||
defaultIcon: 'icon-cluster2-outline',
|
||||
hideInMenu: true,
|
||||
component: './cluster-management/cluster-detail'
|
||||
},
|
||||
{
|
||||
name: 'credentials',
|
||||
path: '/cluster-management/credentials',
|
||||
path: '/resources/credentials',
|
||||
key: 'credentials',
|
||||
icon: 'icon-credential-outline',
|
||||
selectedIcon: 'icon-credential-filled',
|
||||
@@ -322,6 +304,46 @@ const baseRoutes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// Cross-resource consumption (tokens + GPU/CPU instances + storage).
|
||||
// A folder so it matches the other top-level groups; more usage views can
|
||||
// graduate in here later.
|
||||
name: 'billingAndUsage',
|
||||
path: '/usage',
|
||||
key: 'usageGroup',
|
||||
icon: 'icon-usage-outlined',
|
||||
selectedIcon: 'icon-usage-filled',
|
||||
defaultIcon: 'icon-usage-outlined',
|
||||
routes: [
|
||||
{
|
||||
path: '/usage',
|
||||
redirect: '/usage/overview'
|
||||
},
|
||||
{
|
||||
name: 'usage',
|
||||
path: '/usage/overview',
|
||||
key: 'usage',
|
||||
icon: 'icon-usage-outlined',
|
||||
selectedIcon: 'icon-usage-filled',
|
||||
defaultIcon: 'icon-usage-outlined',
|
||||
component: './usage/index'
|
||||
},
|
||||
{
|
||||
name: 'billing',
|
||||
path: '/usage/billing',
|
||||
key: 'billing',
|
||||
icon: 'icon-billing-outlined',
|
||||
selectedIcon: 'icon-billing-filled',
|
||||
defaultIcon: 'icon-billing-outlined',
|
||||
hideInMenu: process.env.ENABLE_ENTERPRISE === 'true',
|
||||
// OSS exposes the menu as a teaser for the enterprise billing
|
||||
// module. The page itself just renders an upsell notice — the real
|
||||
// billing UI lives in the enterprise plugin and shadows this route
|
||||
// via `routes.extensions.ts`.
|
||||
component: './billing'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'accessControl',
|
||||
path: '/access-control',
|
||||
@@ -331,6 +353,20 @@ const baseRoutes = [
|
||||
path: '/access-control',
|
||||
redirect: '/access-control/users'
|
||||
},
|
||||
{
|
||||
name: 'organizations',
|
||||
path: '/access-control/organizations',
|
||||
key: 'organizations',
|
||||
icon: 'icon-org-outlined',
|
||||
selectedIcon: 'icon-org-filled',
|
||||
defaultIcon: 'icon-org-outlined',
|
||||
// OSS exposes the menu to platform admins as a teaser for the
|
||||
// enterprise multi-tenancy module. The page itself just renders
|
||||
// an upsell notice — the real CRUD UI lives in the enterprise
|
||||
// plugin and shadows this route via `routes.extensions.ts`.
|
||||
access: 'canSeeAdmin',
|
||||
component: './organizations'
|
||||
},
|
||||
{
|
||||
name: 'users',
|
||||
path: '/access-control/users',
|
||||
@@ -364,8 +400,8 @@ const baseRoutes = [
|
||||
},
|
||||
{
|
||||
name: 'profile',
|
||||
path: '/profile',
|
||||
key: 'profile',
|
||||
path: '/preferences',
|
||||
key: 'preferences',
|
||||
hideInMenu: true,
|
||||
component: './profile',
|
||||
icon: 'User'
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
const child_process = require('child_process');
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export const getBranchInfo = () => {
|
||||
const latestCommit = child_process
|
||||
.execSync('git rev-parse HEAD')
|
||||
.toString()
|
||||
.trim();
|
||||
const versionTag = child_process
|
||||
.execSync(`git tag --contains ${latestCommit}`)
|
||||
.toString()
|
||||
.trim();
|
||||
return { version: versionTag || '', commitId: latestCommit.slice(0, 7) };
|
||||
// git may be absent (source archive, bare container) or this tree may
|
||||
// not be a git checkout. Swallow the failure and fall back to the env
|
||||
// overrides below — losing build info shouldn't fail the build.
|
||||
let latestCommit = '';
|
||||
let versionTag = '';
|
||||
try {
|
||||
latestCommit = execSync('git rev-parse HEAD').toString().trim();
|
||||
versionTag = execSync(`git tag --contains ${latestCommit}`)
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
// Not a git checkout / git unavailable; rely on env overrides.
|
||||
}
|
||||
// Respect explicit GPUSTACK_UI_* overrides so a wrapping build that
|
||||
// checks this source tree out as a sub-package can stamp its own
|
||||
// release tag and commit id onto the UI (otherwise the panel reports
|
||||
// the host tree's git HEAD, which the wrapper doesn't control).
|
||||
const overrideVersion = process.env.GPUSTACK_UI_VERSION?.trim();
|
||||
const overrideCommitId = process.env.GPUSTACK_UI_COMMIT_ID?.trim();
|
||||
return {
|
||||
version: overrideVersion || versionTag || '',
|
||||
commitId: overrideCommitId || latestCommit.slice(0, 7)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,7 +14,8 @@ export default defineConfig([
|
||||
'dist',
|
||||
'src/.umi/',
|
||||
'src/.umi-production/',
|
||||
'src/.umi-test/'
|
||||
'src/.umi-test/',
|
||||
'src/components/iconfont/'
|
||||
]),
|
||||
{
|
||||
files: ['**/*.{ts,tsx,js,jsx}'],
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@ant-design/pro-components": "3.1.0-0",
|
||||
"@antv/g6": "^5.0.51",
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@gpustack/core-ui": "^1.0.10",
|
||||
"@gpustack/core-ui": "^1.0.42",
|
||||
"@huggingface/gguf": "^0.1.7",
|
||||
"@huggingface/hub": "^0.15.1",
|
||||
"@huggingface/tasks": "^0.11.6",
|
||||
@@ -38,7 +39,6 @@
|
||||
"culori": "^4.0.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"dompurify": "^3.2.6",
|
||||
"driver.js": "^1.3.1",
|
||||
"echarts": "^5.5.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"has-ansi": "^5.0.1",
|
||||
|
||||
@@ -5,6 +5,8 @@ export default (api: IApi) => {
|
||||
const info = JSON.parse(process.env.VERSION || '{}');
|
||||
const env = process.env.NODE_ENV;
|
||||
|
||||
$('html').attr('lang', 'en');
|
||||
|
||||
$('html').attr('data-env', env);
|
||||
|
||||
$('html').attr(
|
||||
|
||||
|
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 "同步完成。"
|
||||
@@ -1,6 +1,10 @@
|
||||
import { applyAccessExtensions } from './access.extensions';
|
||||
|
||||
export default (initialState: { currentUser?: Global.UserInfo }) => {
|
||||
export default (initialState: {
|
||||
currentUser?: Global.UserInfo;
|
||||
hasKubernetesCluster?: boolean;
|
||||
hasResourceEvents?: boolean;
|
||||
}) => {
|
||||
const isPlatformAdmin = !!(
|
||||
initialState &&
|
||||
initialState.currentUser &&
|
||||
@@ -11,6 +15,16 @@ export default (initialState: { currentUser?: Global.UserInfo }) => {
|
||||
initialState.currentUser &&
|
||||
!initialState.currentUser.is_admin
|
||||
);
|
||||
// GPU Service is Kubernetes-only. We only gate visibility down when
|
||||
// the probe in `getInitialState` came back with a definitive answer;
|
||||
// `undefined` (probe failed / not yet ready) collapses to the
|
||||
// role-based default so a transient network blip can't lock anyone
|
||||
// out of the menu.
|
||||
const hasKubernetesCluster = initialState?.hasKubernetesCluster;
|
||||
// Having run GPU/CPU instances or storage (any resource_events) also unlocks
|
||||
// GPU Service / the full Usage page — a user who used it keeps seeing it even
|
||||
// without a current cluster. MaaS-only users (no cluster, no events) don't.
|
||||
const hasResourceEvents = !!initialState?.hasResourceEvents;
|
||||
|
||||
// Predicate roles, top-down by strictness:
|
||||
// * `canSeeAdmin` — strictly platform admin (`users.is_admin`).
|
||||
@@ -18,6 +32,11 @@ export default (initialState: { currentUser?: Global.UserInfo }) => {
|
||||
// * `canSeeOrgAdmin` — admin-style menus that work cross-org
|
||||
// (Dashboard, Resources, Models, Cluster Management). Defaults
|
||||
// to platform admin; extensions widen to include org admins.
|
||||
// * `canSeeGpuService` — GPU Service menu. Anyone allowed to
|
||||
// manage clusters (admins, Org owners) sees it; non-admins fall
|
||||
// through to "show only if a Kubernetes cluster is actually
|
||||
// reachable" so Org members without scheduling access don't see
|
||||
// a dead-end menu item.
|
||||
// * `canManageCurrentOrg` — pages that only make sense inside a
|
||||
// specific org context (member / group management). Defaults to
|
||||
// `false`; extensions widen when both an org is selected AND
|
||||
@@ -27,6 +46,8 @@ export default (initialState: { currentUser?: Global.UserInfo }) => {
|
||||
return applyAccessExtensions({
|
||||
canSeeAdmin: isPlatformAdmin,
|
||||
canSeeOrgAdmin: isPlatformAdmin,
|
||||
canSeeGpuService:
|
||||
isPlatformAdmin || hasKubernetesCluster !== false || hasResourceEvents,
|
||||
canManageCurrentOrg: false,
|
||||
canSeeUser,
|
||||
canDelete: true,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { userSettingsHelperAtom } from '@/atoms/settings';
|
||||
import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user';
|
||||
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
|
||||
import { setAtomStorage } from '@/atoms/utils';
|
||||
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
|
||||
import { COLOR_PRIMARY } from '@/config/theme/constants';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import { enterprisePluginReady } from '@/plugins/enterprise-ready';
|
||||
import { GPUStackPluginManager } from '@/plugins/manager';
|
||||
import { requestConfig } from '@/request-config';
|
||||
@@ -13,6 +14,11 @@ import {
|
||||
} from '@/services/profile/apis';
|
||||
import { fetchSystemConfig } from '@/services/system/query-system-config';
|
||||
import { isOnline } from '@/utils';
|
||||
import {
|
||||
markInitialStateProbed,
|
||||
probeAccessFlags
|
||||
} from '@/utils/access-probes';
|
||||
import { installTenantFetch } from '@/utils/install-fetch';
|
||||
import {
|
||||
IS_FIRST_LOGIN,
|
||||
readState,
|
||||
@@ -22,6 +28,8 @@ import '@gpustack/core-ui/style.css';
|
||||
import { RequestConfig, history, request as umiRequest } from '@umijs/max';
|
||||
import { message } from 'antd';
|
||||
|
||||
installTenantFetch();
|
||||
|
||||
// only for the first login and access from http://localhost
|
||||
|
||||
const checkDefaultPage = async (userInfo: any) => {
|
||||
@@ -39,6 +47,8 @@ export async function getInitialState(): Promise<{
|
||||
fetchUserInfo: () => Promise<Global.UserInfo>;
|
||||
currentUser?: Global.UserInfo;
|
||||
pluginData?: Record<string, any>;
|
||||
hasKubernetesCluster?: boolean;
|
||||
hasResourceEvents?: boolean;
|
||||
}> {
|
||||
const { location } = history;
|
||||
|
||||
@@ -83,6 +93,36 @@ export async function getInitialState(): Promise<{
|
||||
getUpdateCheck();
|
||||
fetchSystemConfig();
|
||||
}
|
||||
// Only commit a substantive user object. A truthy-but-empty
|
||||
// `data` (e.g. server responded 200 with an empty body) would
|
||||
// otherwise look like "logged in" to every `currentUser`
|
||||
// reader and the access seam — break out instead and let the
|
||||
// caller treat the request as failed.
|
||||
if (data && typeof data === 'object' && Object.keys(data).length > 0) {
|
||||
// Commit the identity to atom storage (and so to localStorage)
|
||||
// before returning. The access function — memoized on
|
||||
// `initialState` and run once per commit — reads identity from
|
||||
// localStorage; without this preemptive write the predicate
|
||||
// sees the prior session's identity on its first evaluation
|
||||
// after login, and stays stale until the next identity change
|
||||
// (which usually doesn't come without a manual refresh).
|
||||
try {
|
||||
setAtomStorage(userAtom, data);
|
||||
} catch (err) {
|
||||
console.error('userAtom commit error:', err);
|
||||
}
|
||||
// Fire `onUserFetched` so plugins maintaining identity-scoped
|
||||
// caches can seed them under the new identity before any
|
||||
// caller commits this user to `initialState`. Errors here are
|
||||
// swallowed and logged — fetchUserInfo must still return.
|
||||
try {
|
||||
await getGPUStackPlugin()?.login?.onUserFetched?.(data, {
|
||||
request: umiRequest
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('onUserFetched plugin hook error:', err);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
const data = error?.response?.data;
|
||||
@@ -122,12 +162,24 @@ export async function getInitialState(): Promise<{
|
||||
getAppVersionInfo();
|
||||
|
||||
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
|
||||
const userInfo = await fetchUserInfo();
|
||||
const [userInfo, accessFlags] = await Promise.all([
|
||||
fetchUserInfo(),
|
||||
probeAccessFlags()
|
||||
]);
|
||||
// Record that the probes ran for an authenticated user this page load
|
||||
// (the refresh path) so the layout doesn't re-probe. A failed
|
||||
// fetch (empty user — e.g. unauthenticated deep link that bounces to
|
||||
// login) is NOT marked: the user will log in via SPA afterwards and
|
||||
// the layout becomes responsible for probing.
|
||||
if (userInfo?.username) {
|
||||
markInitialStateProbed();
|
||||
}
|
||||
checkDefaultPage(userInfo);
|
||||
return {
|
||||
fetchUserInfo,
|
||||
currentUser: userInfo,
|
||||
pluginData
|
||||
pluginData,
|
||||
...accessFlags
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
|
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 |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 286 64"><g><g><defs><path id="SVGID_1_" d="M47.5 17.6L25 4.8v52.6l9-5.2V37.4l6.8 3.9-.1-10.1-6.7-3.9v-5.9l13.5 7.9z"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" overflow="visible"/></clipPath><g clip-path="url(#SVGID_2_)"><linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-1.6" y1="335.05" x2="53.6" y2="335.05" gradientTransform="translate(0 -304)"><stop offset="0" stop-color="#ff6f00"/><stop offset="1" stop-color="#ffa800"/></linearGradient><path d="M-1.6 4.6h55.2v52.9H-1.6V4.6z" fill="url(#SVGID_3_)"/></g></g></g><g><g><defs><path id="SVGID_4_" d="M.5 17.6L23 4.8v52.6l-9-5.2V21.4L.5 29.3z"/></defs><clipPath id="SVGID_5_"><use xlink:href="#SVGID_4_" overflow="visible"/></clipPath><g clip-path="url(#SVGID_5_)"><linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-1.9" y1="335.05" x2="53.3" y2="335.05" gradientTransform="translate(0 -304)"><stop offset="0" stop-color="#ff6f00"/><stop offset="1" stop-color="#ffa800"/></linearGradient><path d="M-1.9 4.6h55.2v52.9H-1.9V4.6z" fill="url(#SVGID_6_)"/></g></g></g><path style="fill:#425066" d="M88.2 21.1h-10v27.7h-5.6V21.1h-10v-4.5h25.6v4.5z"/><path style="fill:#425066" d="M94.9 49.2c-3.4 0-6.2-1.1-8.3-3.2-2.1-2.1-3.2-5-3.2-8.6v-.7c0-2.2.4-4.4 1.4-6.4.9-1.8 2.2-3.3 3.9-4.4 1.7-1.1 3.6-1.6 5.6-1.6 3.3 0 5.8 1 7.6 3.1s2.7 5 2.7 8.8v2.2H88.9c.1 1.8.8 3.4 2 4.7 1.2 1.2 2.7 1.8 4.4 1.7 2.4.1 4.6-1.1 6-3l2.9 2.8c-1 1.4-2.3 2.6-3.8 3.3-1.8 1-3.6 1.4-5.5 1.3zm-.6-20.5c-1.4-.1-2.7.5-3.6 1.5-1 1.2-1.6 2.7-1.7 4.3h10.3v-.4c-.1-1.8-.6-3.2-1.4-4.1-1-.8-2.3-1.4-3.6-1.3zm19.4-3.9l.2 2.8c1.7-2.1 4.3-3.3 7-3.2 5 0 7.5 2.9 7.6 8.6v15.8h-5.4V33.3c0-1.5-.3-2.6-1-3.4-.7-.7-1.7-1.1-3.2-1.1-2.1-.1-4 1.1-4.9 2.9v17h-5.4v-24l5.1.1zm32.2 17.5c0-.9-.4-1.7-1.2-2.2-1.2-.7-2.6-1.1-3.9-1.3-1.6-.3-3.1-.8-4.6-1.5-2.7-1.3-4-3.2-4-5.6 0-2 1-4 2.6-5.2 1.7-1.4 4-2.1 6.6-2.1 2.9 0 5.2.7 6.9 2.1 1.7 1.3 2.7 3.4 2.6 5.5h-5.4c0-1-.4-1.9-1.2-2.6-.9-.7-1.9-1.1-3.1-1-1 0-2 .2-2.9.8-.7.5-1.1 1.3-1.1 2.2 0 .8.4 1.5 1 1.9.7.5 2.1.9 4.2 1.4 1.7.3 3.4.9 5 1.7 1.1.5 2 1.3 2.7 2.3.6 1 .9 2.1.9 3.3 0 2.1-1 4-2.7 5.2-1.8 1.3-4.1 2-7 2-1.8 0-3.6-.3-5.2-1.1-1.4-.6-2.7-1.6-3.6-2.9-.8-1.2-1.3-2.6-1.3-4h5.2c0 1.1.5 2.2 1.4 2.9 1 .7 2.3 1.1 3.5 1 1.4 0 2.5-.3 3.2-.8 1-.4 1.4-1.2 1.4-2zm8.1-5.7c0-2.2.4-4.4 1.4-6.3.9-1.8 2.2-3.3 3.9-4.3 1.8-1 3.8-1.6 5.8-1.5 3.2 0 5.9 1 7.9 3.1s3.1 4.8 3.3 8.3v1.3c0 2.2-.4 4.3-1.4 6.3-.8 1.8-2.2 3.3-3.9 4.3-1.8 1-3.8 1.6-5.9 1.5-3.4 0-6.1-1.1-8.1-3.4-2-2.2-3-5.2-3.1-9l.1-.3zm5.3.5c0 2.5.5 4.4 1.5 5.8 1.8 2.3 5.1 2.8 7.5 1 .4-.3.7-.6 1-1 1-1.4 1.5-3.5 1.5-6.2 0-2.4-.5-4.3-1.6-5.8-1.7-2.3-5-2.8-7.4-1.1-.4.3-.8.7-1.1 1-.8 1.4-1.4 3.5-1.4 6.3zm33.1-7.3c-.7-.1-1.5-.2-2.2-.2-2.5 0-4.1.9-5 2.8v16.4h-5.4v-24h5.1l.1 2.7c1.3-2.1 3.1-3.1 5.4-3.1.6 0 1.3.1 1.9.3l.1 5.1zm22.5 5.3h-13v13.7h-5.6V16.6h20.5v4.5h-14.9v9.6h13v4.4zm10.7 13.7h-5.4V16.5h5.4v32.3zm3.9-12.2c0-2.2.4-4.4 1.4-6.3.9-1.8 2.2-3.3 3.9-4.3 1.8-1 3.8-1.6 5.8-1.5 3.2 0 5.9 1 7.9 3.1s3.1 4.8 3.3 8.3v1.3c0 2.2-.4 4.3-1.3 6.3-.8 1.8-2.2 3.3-3.9 4.3-1.8 1-3.8 1.6-5.9 1.5-3.4 0-6.1-1.1-8.1-3.4-2-2.2-3.1-5.2-3.1-9v-.3zm5.4.5c0 2.5.5 4.4 1.5 5.8 1 1.4 2.6 2.2 4.3 2.1 1.7.1 3.3-.7 4.2-2.1 1-1.4 1.5-3.5 1.5-6.2 0-2.4-.5-4.3-1.6-5.8-1.7-2.3-5-2.8-7.4-1.1-.4.3-.8.7-1.1 1-.9 1.4-1.4 3.5-1.4 6.3zm41.2 4.3l3.8-16.5h5.2l-6.5 24h-4.4l-5.1-16.5-5.1 16.5h-4.4l-6.6-24h5.3l3.9 16.4 4.9-16.4h4.1l4.9 16.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 21 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 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AlibabaCloud</title><path d="M14.752 4.64h5.274C22.242 4.64 24 6.475 24 8.691V15.8a3.947 3.947 0 01-3.974 3.975h-5.274l1.299-1.835 3.822-1.222c.688-.23 1.146-.918 1.146-1.605v-5.81c0-.687-.458-1.375-1.146-1.605L16.05 6.475l-1.3-1.835zM2.98 15.111c0 .688.46 1.376 1.147 1.606l3.822 1.146 1.3 1.835H3.974A3.947 3.947 0 010 15.723V8.69c0-2.216 1.758-4.05 3.975-4.05h5.273L7.95 6.474 4.127 7.697c-.688.23-1.146.918-1.146 1.606v5.808z" fill="#FF6A00"></path><path d="M16.051 11.213H8.025v1.835h8.026v-1.835z" fill="#FF6A00"></path></svg>
|
||||
|
After Width: | Height: | Size: 656 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>BaiLian</title><path d="M6.336 8.919v6.162l5.335-3.083L6.337 8.92z" fill="#1C54E3"></path><path d="M21.394 5.288s-.006-.006-.01-.006L17.01 2.754 6.336 8.92l5.335 3.082 9.701-5.6.016-.01a.635.635 0 00.006-1.1v-.003z" fill="#AA9AFF"></path><path d="M21.71 12.465a.62.62 0 00-.316.085s-.006 0-.009.003l-4.375 2.528 5.05 2.915h.006a2.06 2.06 0 00.28-1.04v-3.855a.637.637 0 00-.636-.636z" fill="#00EAD1"></path><path d="M22.06 17.996l-5.05-2.915L6.34 21.242l4.27 2.465s.016.006.022.012a2.102 2.102 0 002.093 0c.006-.003.016-.006.022-.012l8.538-4.93c.003 0 .006-.003.01-.006.321-.183.589-.45.775-.772h-.006l-.004-.003z" fill="#00CEC9"></path><path d="M11.672 11.998l-5.336 3.083-1.444.832-3.605 2.083H1.28c.173.303.416.555.709.738l.078.044.016.01.02.012 4.232 2.442 10.671-6.161-5.335-3.082z" fill="#00EAD1"></path><path d="M12.74.29c-.1-.06-.208-.107-.315-.148-.02-.006-.038-.016-.057-.022a2.121 2.121 0 00-.7-.12c-.233 0-.457.038-.668.11l-.031.01a2.196 2.196 0 00-.372.17L2.068 5.222s-.003 0-.006.003c-.324.183-.592.451-.781.773h.006l5.049 2.918L17.01 2.758 12.74.29z" fill="#7347FF"></path><path d="M1.287 6.001H1.28A2.06 2.06 0 001 7.041v9.915c0 .378.1.735.28 1.043h.007l5.049-2.918V8.919l-5.05-2.918z" fill="#0423DA"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 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;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
.ant-layout-sider-children {
|
||||
border-inline: none;
|
||||
border-radius: 0;
|
||||
padding-inline-end: 0;
|
||||
padding-block-end: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,13 @@ export const fromClusterCreationAtom = atom(false);
|
||||
export const clusterSessionAtom = atom<{
|
||||
firstAddWorker: boolean;
|
||||
firstAddCluster: boolean;
|
||||
presetClusterType?: 'model' | 'gpu';
|
||||
// Provider to preselect when the create flow opens — set by the
|
||||
// empty-state CTA on feature pages that need a specific provider
|
||||
// (e.g. GPU Service can only schedule on Kubernetes, so its
|
||||
// "Add Cluster" button skips provider catalog and lands on the
|
||||
// K8s configure step). Consumed once by ClusterCreate on mount.
|
||||
providerHint?: string;
|
||||
} | null>(null);
|
||||
|
||||
export const clusterDetailAtom = atom<ClusterListItem | null>(null);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ClusterListItem } from '@/pages/cluster-management/config/types';
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const currentClusterAtom = atom<
|
||||
(Partial<ClusterListItem> & { label?: string; value?: number }) | null
|
||||
>(null);
|
||||
@@ -55,3 +55,10 @@ export const userSettingsHelperAtom = atom(
|
||||
}
|
||||
);
|
||||
export const hideModalTemporarilyAtom = atom<boolean>(false);
|
||||
|
||||
export const collapsedMenuGroupsAtom = atomWithStorage<string[]>(
|
||||
'collapsedMenuGroups',
|
||||
[],
|
||||
undefined,
|
||||
{ getOnInit: true }
|
||||
);
|
||||
|
||||
@@ -3,6 +3,15 @@ import { atomWithStorage } from 'jotai/utils';
|
||||
|
||||
export const userAtom = atomWithStorage<any>('userInfo', null);
|
||||
|
||||
// Backs the `currentOrganizationId` localStorage key. Stays null in
|
||||
// builds with no Org context (single-tenant), and is shared with any
|
||||
// extension that persists the same key so both sides stay in sync
|
||||
// without one side having to import from the other.
|
||||
export const currentOrganizationIdAtom = atomWithStorage<number | null>(
|
||||
'currentOrganizationId',
|
||||
null
|
||||
);
|
||||
|
||||
export const GPUStackVersionAtom = atom<{
|
||||
version: string;
|
||||
git_commit: string;
|
||||
@@ -23,16 +32,15 @@ export const UpdateCheckAtom = atom<{
|
||||
latest_version: ''
|
||||
});
|
||||
|
||||
export const initialPasswordAtom = atomWithStorage<string>(
|
||||
'initialPassword',
|
||||
''
|
||||
);
|
||||
export const initialPasswordAtom = atom<string>('');
|
||||
|
||||
// Namespace the server creates for an Org's resources on each Kubernetes
|
||||
// cluster. The format must match the backend's ``get_namespace_name``
|
||||
// helper — ``gpustack-{slug}`` — because the GPU-instance / storage CRDs
|
||||
// helper — ``gpustack-{name}`` — because the GPU-instance / storage CRDs
|
||||
// (worker.gpustack.ai/v1) are namespaced and the server-side admission
|
||||
// keys off this exact name.
|
||||
// keys off this exact name. The identifier column on the unified
|
||||
// Principal table is now ``name`` (post identity-consolidation rename
|
||||
// of the legacy ``slug``); the namespace prefix is unchanged.
|
||||
//
|
||||
// Resolution path:
|
||||
// 1. The Org the caller is currently acting under — the enterprise
|
||||
@@ -75,27 +83,61 @@ const getStoredCurrentOrgId = (): number | null => {
|
||||
// the caller's member orgs; ``allOrganizations`` is admin-only (every
|
||||
// Org on the platform) so admin sessions can resolve any owner Org id.
|
||||
// Both are checked because ``currentOrganizationId`` is null in the
|
||||
// admin "All" view but a member org's slug might still cover the
|
||||
// admin "All" view but a member org's ``name`` might still cover the
|
||||
// cluster-owner fallback.
|
||||
const ORG_CACHE_KEYS = ['organizationList', 'allOrganizations'] as const;
|
||||
|
||||
const lookupOrgNamespace = (id: number | null): string | null => {
|
||||
export interface CachedOrg {
|
||||
id: number;
|
||||
name?: string;
|
||||
// The platform Org (single global tenant). Its models are NOT
|
||||
// namespaced in ``/v1/models`` — they appear under their bare name.
|
||||
is_platform?: boolean;
|
||||
}
|
||||
|
||||
// Resolve the cached Org record for an owner/principal id by scanning both
|
||||
// org caches. ``organizationList`` (the caller's member orgs) is checked
|
||||
// alongside the admin-only ``allOrganizations`` so member sessions resolve
|
||||
// too. Id types vary between localStorage payloads (some writers stringify,
|
||||
// others persist as a JSON number), so compare as strings — strict equality
|
||||
// would silently miss those cases.
|
||||
export const getOrgById = (
|
||||
id: number | string | null | undefined
|
||||
): CachedOrg | null => {
|
||||
if (id == null) return null;
|
||||
// Normalise both sides to strings — the stored id type varies between
|
||||
// localStorage payloads (some writers stringify, others persist as a
|
||||
// JSON number); strict equality would silently miss those cases.
|
||||
const target = String(id);
|
||||
for (const key of ORG_CACHE_KEYS) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) continue;
|
||||
const list = JSON.parse(raw) as Array<{ id: number; slug?: string }>;
|
||||
const list = JSON.parse(raw) as CachedOrg[];
|
||||
if (!Array.isArray(list)) continue;
|
||||
const match = list.find((item) => String(item?.id) === target);
|
||||
if (match?.slug) return `gpustack-${match.slug}`;
|
||||
if (match) return match;
|
||||
} catch {
|
||||
// ignore malformed cache; continue checking other keys
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Bare Org *name* (e.g. ``org1``) for an owner/principal id, or null.
|
||||
export const getOrgNameById = (
|
||||
id: number | string | null | undefined
|
||||
): string | null => {
|
||||
return getOrgById(id)?.name ?? null;
|
||||
};
|
||||
|
||||
const lookupOrgNamespace = (id: number | null): string | null => {
|
||||
const name = getOrgNameById(id);
|
||||
return name ? `gpustack-${name}` : null;
|
||||
};
|
||||
|
||||
// The Org the caller is currently acting under, or null in the admin-"All"
|
||||
// context. The org-switcher reloads the page on switch, so the list pages
|
||||
// always show this org's resources — which is how ``/v1/models`` namespaces
|
||||
// their model ids (``{org}/{name}``). Callers reconstructing that id use this
|
||||
// as the fallback owner when a row carries no explicit ``owner_principal_id``.
|
||||
export const getCurrentOrg = (): CachedOrg | null => {
|
||||
return getOrgById(getStoredCurrentOrgId());
|
||||
};
|
||||
|
||||
@@ -6,12 +6,17 @@ export const clearStorageUserSettings = () => {
|
||||
const savedSettings = JSON.parse(
|
||||
localStorage.getItem('userSettings') || '{}'
|
||||
);
|
||||
// colorPrimary is an enterprise-wide branding setting (set by admins
|
||||
// and applied by `onAppInit` from /enterprise/settings), not a per-user
|
||||
// preference. Preserve it across login — otherwise the next layout
|
||||
// mount triggers `atomWithStorage.onMount`, re-reads localStorage,
|
||||
// and falls back to the default color until a full page refresh
|
||||
// re-runs `applyEnterpriseSettings`.
|
||||
localStorage.setItem(
|
||||
'userSettings',
|
||||
JSON.stringify({
|
||||
...savedSettings,
|
||||
hideAddResourceModal: false,
|
||||
colorPrimary: undefined
|
||||
hideAddResourceModal: false
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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;
|
||||