Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
207cef350f | ||
|
|
fa3fed8671 | ||
|
|
dc41b61d69 | ||
|
|
3a1565690d | ||
|
|
1bb9fd73db | ||
|
|
39024b1d46 | ||
|
|
4ecaa0a326 | ||
|
|
e1af27e11c | ||
|
|
f4ebd8d7d1 | ||
|
|
bd58a911d5 | ||
|
|
5321973ebe | ||
|
|
0a1f875590 | ||
|
|
ea2c9870a7 | ||
|
|
23f0e4398a | ||
|
|
b342174fc2 | ||
|
|
d07d3b2c99 | ||
|
|
9d0a5d01b0 | ||
|
|
1e97d29fca | ||
|
|
350f398cde | ||
|
|
8a15172316 | ||
|
|
185e3475e7 | ||
|
|
64720ccbfa | ||
|
|
db17192e55 | ||
|
|
a1dd1ec861 | ||
|
|
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 | ||
|
|
2dae886fd1 | ||
|
|
beb74b3c47 | ||
|
|
d93ee98473 | ||
|
|
f081f0c1cf | ||
|
|
4e49c399d0 | ||
|
|
2527555945 | ||
|
|
5ef671f047 | ||
|
|
10781da086 | ||
|
|
c279a7f186 | ||
|
|
7dd1c9ac5e | ||
|
|
125a5be91f | ||
|
|
e25d48cc89 | ||
|
|
5f96f367a6 | ||
|
|
01fe2e66fb | ||
|
|
1b74285de9 | ||
|
|
b13ba0a229 | ||
|
|
9e0dd5b003 | ||
|
|
4128de61f9 | ||
|
|
fc900c8ce6 | ||
|
|
63ac4e0e75 | ||
|
|
7ee1635833 | ||
|
|
7d0cfeff77 | ||
|
|
7fa44644fd | ||
|
|
e9152e8725 | ||
|
|
7ea7f6666d | ||
|
|
3ae49dc826 | ||
|
|
75d7eff63b | ||
|
|
8618718b71 | ||
|
|
8d17000272 | ||
|
|
df53774628 | ||
|
|
68235e188a | ||
|
|
f37fad11b1 | ||
|
|
93fa277bdd | ||
|
|
ea7fa63079 | ||
|
|
d15db4b4dd | ||
|
|
ed06d57a31 | ||
|
|
168c55990f | ||
|
|
2889ba7c78 | ||
|
|
896b8ef326 | ||
|
|
f08d5c904c | ||
|
|
4be5fb4d3d | ||
|
|
a2d5d55518 | ||
|
|
e1b28b01df | ||
|
|
b6bbe880a0 | ||
|
|
65ad57fc4c | ||
|
|
469e63b898 | ||
|
|
d471233f83 | ||
|
|
01576212a3 | ||
|
|
898c7f1be2 | ||
|
|
705b33e307 | ||
|
|
2592f44bc2 | ||
|
|
9914db6b96 | ||
|
|
bb93ad37a1 | ||
|
|
93bc632088 | ||
|
|
ab89ddffc8 | ||
|
|
7794e57bb1 | ||
|
|
7c15bb6ef6 | ||
|
|
ac99156c90 | ||
|
|
c68f546be9 | ||
|
|
217285223c | ||
|
|
3144328874 | ||
|
|
26880b4612 | ||
|
|
e7aac14ef1 | ||
|
|
52211cd4ea | ||
|
|
331c59b7c1 | ||
|
|
b605b17561 | ||
|
|
33163ac96c | ||
|
|
757af11ab3 | ||
|
|
81a87ef362 | ||
|
|
0cb260f854 | ||
|
|
0ffc2ee924 | ||
|
|
e4992e8313 | ||
|
|
519020077a | ||
|
|
6fccee3711 | ||
|
|
597a86562a | ||
|
|
16449613e0 | ||
|
|
6c161c6ae7 | ||
|
|
48b357b1c6 | ||
|
|
a795544074 | ||
|
|
8711c27b78 | ||
|
|
d48aa99dcc | ||
|
|
5f3b9b88ff | ||
|
|
f566ea3b63 | ||
|
|
8ba77beeed | ||
|
|
128f59ed6f | ||
|
|
c92f56ec8f | ||
|
|
daa3d74500 | ||
|
|
ad7fc3a993 | ||
|
|
084822814d | ||
|
|
3a58bd1eef | ||
|
|
94d3fa356c | ||
|
|
7d75714662 | ||
|
|
b057d52499 | ||
|
|
2d726ded30 | ||
|
|
f45d9bda61 | ||
|
|
684c1ab600 | ||
|
|
3ac2720ba8 | ||
|
|
7b743f35b3 | ||
|
|
e1b2a4aee2 | ||
|
|
308c350582 | ||
|
|
41c9b86fd9 | ||
|
|
46b9855c87 | ||
|
|
c667de90ed | ||
|
|
cf13e3bed0 | ||
|
|
0956538b79 | ||
|
|
f2a4ab2654 | ||
|
|
aeabdab781 | ||
|
|
f04b5cdc27 | ||
|
|
b947e3a8b9 | ||
|
|
ae3148a86f | ||
|
|
78bf76e965 | ||
|
|
7beb5756f7 | ||
|
|
a2b0236ea3 | ||
|
|
144914110a | ||
|
|
c73609d155 | ||
|
|
53cd8ba00c | ||
|
|
1c09cc6c8c | ||
|
|
06160546a6 | ||
|
|
0b292829fa | ||
|
|
392c51fced | ||
|
|
144d5a6d5f | ||
|
|
43dfc7e1f0 | ||
|
|
adde7fba37 | ||
|
|
33fe180997 | ||
|
|
a95b7e9561 | ||
|
|
08330e0b82 | ||
|
|
c74118e913 | ||
|
|
bb3cef89c1 | ||
|
|
589936199c | ||
|
|
710e2e1996 | ||
|
|
aa794c7646 | ||
|
|
48779aed29 | ||
|
|
3907e1ac76 | ||
|
|
4d6af4fbf8 | ||
|
|
9c3b0efb30 | ||
|
|
8bcb7ffe4d | ||
|
|
77f0740ec9 | ||
|
|
8b5e407758 | ||
|
|
6c88a5554f | ||
|
|
d779ee06b1 | ||
|
|
f873d4e245 | ||
|
|
0a0e03dc68 | ||
|
|
f0e19c8934 | ||
|
|
a6af51dc32 | ||
|
|
fd1a079271 | ||
|
|
9840a48301 | ||
|
|
2dc7c9adf1 | ||
|
|
a86bfe7fd3 | ||
|
|
d99475bb80 | ||
|
|
194b7aff2e | ||
|
|
f6d5733183 | ||
|
|
0ca055b8e8 | ||
|
|
9aae2c7f50 | ||
|
|
c29ad1270b | ||
|
|
49a60c25af | ||
|
|
39bcc158bb | ||
|
|
4f02bcd40c | ||
|
|
556c4a434d | ||
|
|
d389b035b7 | ||
|
|
9447088f89 | ||
|
|
64955c26a7 | ||
|
|
68519223fa | ||
|
|
e72cacc4b2 | ||
|
|
ed4be2328f | ||
|
|
e241c6cb6f | ||
|
|
6e98fb4274 | ||
|
|
83f6f41787 | ||
|
|
18c10e5839 | ||
|
|
11de47c813 | ||
|
|
85ea619b18 | ||
|
|
3f57cf21f7 | ||
|
|
b75c773879 | ||
|
|
95070ae382 |
@@ -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 +0,0 @@
|
|||||||
src/components/icon-font/iconfont/iconfont.js
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
extends: require.resolve('@umijs/max/eslint'),
|
|
||||||
rules: {
|
|
||||||
'react/no-unstable-nested-components': 1,
|
|
||||||
'no-unused-vars': 'off',
|
|
||||||
'no-undef': 'error',
|
|
||||||
'@typescript-eslint/no-unused-vars': 'off',
|
|
||||||
'@typescript-eslint/class-name-casing': 'off'
|
|
||||||
},
|
|
||||||
globals: {
|
|
||||||
Global: 'readonly',
|
|
||||||
React: 'readonly',
|
|
||||||
JSX: 'readonly'
|
|
||||||
},
|
|
||||||
ignorePatterns: ['public/static/']
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch: {}
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
@@ -117,3 +118,20 @@ jobs:
|
|||||||
remote_path: releases/${{ steps.version.outputs.version }}.tar.gz
|
remote_path: releases/${{ steps.version.outputs.version }}.tar.gz
|
||||||
accelerate: true
|
accelerate: true
|
||||||
clean: false
|
clean: false
|
||||||
|
|
||||||
|
trigger-backend:
|
||||||
|
needs: build-publish
|
||||||
|
if: (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Dispatch backend build
|
||||||
|
uses: peter-evans/repository-dispatch@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.DISPATCH_PAT }}
|
||||||
|
repository: gpustack/gpustack
|
||||||
|
event-type: ui-built
|
||||||
|
client-payload: |
|
||||||
|
{
|
||||||
|
"ref": "${{ github.ref }}",
|
||||||
|
"sha": "${{ github.sha }}"
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,3 +13,6 @@
|
|||||||
.swc
|
.swc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
|
.claude/settings.local.json
|
||||||
|
/dist.zip
|
||||||
|
.cache
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
{
|
{
|
||||||
"*.{md,json}": ["prettier --cache --write"],
|
"*.{md,json}": ["prettier --cache --write"],
|
||||||
"*.{js,jsx}": ["max lint --fix --eslint-only", "prettier --cache --write"],
|
"*.{js,jsx}": ["max lint --fix --eslint-only", "prettier --cache --write"],
|
||||||
"*.{css,less}": [
|
"*.{css,less}": ["prettier --cache --write"],
|
||||||
"max lint --fix --stylelint-only",
|
|
||||||
"prettier --cache --write"
|
|
||||||
],
|
|
||||||
"!public/vs/**": [],
|
"!public/vs/**": [],
|
||||||
"*.ts?(x)": [
|
"*.ts?(x)": [
|
||||||
"max lint --fix --eslint-only",
|
"max lint --fix --eslint-only",
|
||||||
"prettier --cache --parser=typescript --write"
|
"prettier --cache --parser=typescript --write"
|
||||||
],
|
],
|
||||||
"src/locales/**/*.ts": ["npx tsx src/locales/check.ts"]
|
"src/locales/**/*.ts": ["node --import tsx src/locales/check.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ node_modules
|
|||||||
.umi-production
|
.umi-production
|
||||||
public/static/*.js
|
public/static/*.js
|
||||||
public/static/*.css
|
public/static/*.css
|
||||||
src/components/icon-font/iconfont/iconfont.js
|
src/components/iconfont/
|
||||||
src/components/icon-font/iconfont/*.css
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ module.exports = {
|
|||||||
rules: {
|
rules: {
|
||||||
'selector-class-pattern': null
|
'selector-class-pattern': null
|
||||||
},
|
},
|
||||||
ignoreFiles: ['public/static/*.css']
|
ignoreFiles: ['public/static/*.css', 'src/components/iconfont/iconfont.css']
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# 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`.
|
||||||
|
- **Status display** (success/failed/processing/warning): `StatusTag`.
|
||||||
|
- **Permission-gated visibility**: `Access` / `useAccess`.
|
||||||
|
- **Request hooks**: `useRequest` / `useQueryData` / `useQueryDataList`.
|
||||||
|
- **Table data fetching**: `useTableFetch`.
|
||||||
|
- **Submit guard** (prevent double-submit): `useSubmitLock`.
|
||||||
|
- **Tabbed forms**: `ScrollSpyTabs`.
|
||||||
|
|
||||||
|
# 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,5 +1,6 @@
|
|||||||
import { defineConfig } from '@umijs/max';
|
import { defineConfig } from '@umijs/max';
|
||||||
import keepAlive from './keep-alive';
|
import keepAlive from './keep-alive';
|
||||||
|
import { extraMfsuExclude } from './mfsu.extensions';
|
||||||
import { compressionPluginConfig, monacoPluginConfig } from './plugins';
|
import { compressionPluginConfig, monacoPluginConfig } from './plugins';
|
||||||
import proxy from './proxy';
|
import proxy from './proxy';
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
@@ -19,6 +20,9 @@ export default defineConfig({
|
|||||||
history: {
|
history: {
|
||||||
type: 'hash'
|
type: 'hash'
|
||||||
},
|
},
|
||||||
|
define: {
|
||||||
|
'process.env.ENABLE_ENTERPRISE': process.env.ENABLE_ENTERPRISE
|
||||||
|
},
|
||||||
analyze: {
|
analyze: {
|
||||||
analyzerMode: 'server',
|
analyzerMode: 'server',
|
||||||
analyzerPort: 8888,
|
analyzerPort: 8888,
|
||||||
@@ -29,7 +33,7 @@ export default defineConfig({
|
|||||||
defaultSizes: 'parsed' // stat // gzip
|
defaultSizes: 'parsed' // stat // gzip
|
||||||
},
|
},
|
||||||
mfsu: {
|
mfsu: {
|
||||||
exclude: ['lodash', 'ml-pca']
|
exclude: ['lodash', 'ml-pca', ...extraMfsuExclude]
|
||||||
},
|
},
|
||||||
base: process.env.npm_config_base || '/',
|
base: process.env.npm_config_base || '/',
|
||||||
...(isProduction
|
...(isProduction
|
||||||
@@ -73,6 +77,7 @@ export default defineConfig({
|
|||||||
antd: {
|
antd: {
|
||||||
style: 'less'
|
style: 'less'
|
||||||
},
|
},
|
||||||
|
title: 'GPUStack',
|
||||||
hash: true,
|
hash: true,
|
||||||
access: {},
|
access: {},
|
||||||
model: {},
|
model: {},
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Identity hook for build-time mfsu.exclude extensions. Tooling may
|
||||||
|
// overwrite this file to add package names that must skip MFSU's
|
||||||
|
// pre-bundling; the original is restored on cleanup. Mirrors
|
||||||
|
// `src/request.extensions.ts` / `src/access.extensions.ts`.
|
||||||
|
//
|
||||||
|
// MFSU bundles node_modules into immutable chunks at dev startup, so
|
||||||
|
// workspace-linked packages whose source you edit during dev must be
|
||||||
|
// excluded here or HMR won't pick up changes.
|
||||||
|
export const extraMfsuExclude: string[] = [];
|
||||||
@@ -31,5 +31,6 @@ export default function createProxyTable(target?: string) {
|
|||||||
},
|
},
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
return proxyTable;
|
return proxyTable;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Identity hook for build-time route extensions. Tooling may overwrite
|
||||||
|
// this file to inject additional routes; the original is restored on cleanup.
|
||||||
|
export const applyRouteExtensions = <T>(base: T): T => base;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { keepAliveRoutes } from './keep-alive';
|
import { keepAliveRoutes } from './keep-alive';
|
||||||
|
import { applyRouteExtensions } from './routes.extensions';
|
||||||
|
|
||||||
export default [
|
const baseRoutes = [
|
||||||
{
|
{
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
@@ -8,7 +9,10 @@ export default [
|
|||||||
icon: 'icon-dashboard',
|
icon: 'icon-dashboard',
|
||||||
selectedIcon: 'icon-dashboard-filled',
|
selectedIcon: 'icon-dashboard-filled',
|
||||||
defaultIcon: 'icon-dashboard',
|
defaultIcon: 'icon-dashboard',
|
||||||
access: 'canSeeAdmin',
|
// `canSeeOrgAdmin` widens to anyone the access seam grants
|
||||||
|
// admin-ish visibility — by default platform admin, plus
|
||||||
|
// whatever the routes extension chooses to allow.
|
||||||
|
access: 'canSeeOrgAdmin',
|
||||||
component: './dashboard',
|
component: './dashboard',
|
||||||
routes: []
|
routes: []
|
||||||
},
|
},
|
||||||
@@ -32,7 +36,7 @@ export default [
|
|||||||
icon: 'icon-chat',
|
icon: 'icon-chat',
|
||||||
selectedIcon: 'icon-chat-filled',
|
selectedIcon: 'icon-chat-filled',
|
||||||
defaultIcon: 'icon-chat',
|
defaultIcon: 'icon-chat',
|
||||||
component: './playground/index'
|
component: './playground/chat/index'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'embedding',
|
name: 'embedding',
|
||||||
@@ -42,7 +46,7 @@ export default [
|
|||||||
icon: 'icon-embedding',
|
icon: 'icon-embedding',
|
||||||
selectedIcon: 'icon-embedding-filled',
|
selectedIcon: 'icon-embedding-filled',
|
||||||
defaultIcon: 'icon-embedding',
|
defaultIcon: 'icon-embedding',
|
||||||
component: './playground/embedding'
|
component: './playground/embedding/index'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'rerank',
|
name: 'rerank',
|
||||||
@@ -52,7 +56,7 @@ export default [
|
|||||||
icon: 'icon-reranker',
|
icon: 'icon-reranker',
|
||||||
selectedIcon: 'icon-reranker-filled',
|
selectedIcon: 'icon-reranker-filled',
|
||||||
defaultIcon: 'icon-reranker',
|
defaultIcon: 'icon-reranker',
|
||||||
component: './playground/rerank'
|
component: './playground/rerank/index'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'text2images',
|
name: 'text2images',
|
||||||
@@ -62,7 +66,7 @@ export default [
|
|||||||
icon: 'icon-image1',
|
icon: 'icon-image1',
|
||||||
selectedIcon: 'icon-image-filled',
|
selectedIcon: 'icon-image-filled',
|
||||||
defaultIcon: 'icon-image1',
|
defaultIcon: 'icon-image1',
|
||||||
component: './playground/images'
|
component: './playground/images/index'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'speech',
|
name: 'speech',
|
||||||
@@ -72,8 +76,19 @@ export default [
|
|||||||
icon: 'icon-audio1',
|
icon: 'icon-audio1',
|
||||||
selectedIcon: 'icon-audio-filled',
|
selectedIcon: 'icon-audio-filled',
|
||||||
defaultIcon: 'icon-audio1',
|
defaultIcon: 'icon-audio1',
|
||||||
component: './playground/speech'
|
component: './playground/speech/index'
|
||||||
}
|
}
|
||||||
|
// {
|
||||||
|
// name: 'video',
|
||||||
|
// title: 'Video',
|
||||||
|
// path: '/playground/video',
|
||||||
|
// key: 'video',
|
||||||
|
// icon: 'icon-video-outline',
|
||||||
|
// hideInMenu: false,
|
||||||
|
// selectedIcon: 'icon-video-filled02',
|
||||||
|
// defaultIcon: 'icon-video-outline',
|
||||||
|
// component: './playground/video'
|
||||||
|
// }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -92,40 +107,9 @@ export default [
|
|||||||
icon: 'icon-layers',
|
icon: 'icon-layers',
|
||||||
selectedIcon: 'icon-layers-filled',
|
selectedIcon: 'icon-layers-filled',
|
||||||
defaultIcon: 'icon-layers',
|
defaultIcon: 'icon-layers',
|
||||||
access: 'canSeeAdmin',
|
access: 'canSeeOrgAdmin',
|
||||||
component: './llmodels/catalog'
|
component: './llmodels/catalog'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'deployment',
|
|
||||||
path: '/models/deployments',
|
|
||||||
key: 'modelDeployments',
|
|
||||||
icon: 'icon-rocket-launch1',
|
|
||||||
selectedIcon: 'icon-rocket-launch-fill',
|
|
||||||
defaultIcon: 'icon-rocket-launch1',
|
|
||||||
access: 'canSeeAdmin',
|
|
||||||
component: './llmodels/index'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'routes',
|
|
||||||
path: '/models/routes',
|
|
||||||
key: 'routes',
|
|
||||||
icon: 'icon-captive_portal',
|
|
||||||
selectedIcon: 'icon-captive_portal',
|
|
||||||
defaultIcon: 'icon-captive_portal',
|
|
||||||
access: 'canSeeAdmin',
|
|
||||||
component: './model-routes/index'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'providers',
|
|
||||||
path: '/models/providers',
|
|
||||||
key: 'modelProviders',
|
|
||||||
icon: 'icon-extension-outline',
|
|
||||||
selectedIcon: 'icon-extension-filled',
|
|
||||||
defaultIcon: 'icon-extension-outline',
|
|
||||||
access: 'canSeeAdmin',
|
|
||||||
component: './maas-provider/index'
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'userModels',
|
name: 'userModels',
|
||||||
path: '/models/user-models',
|
path: '/models/user-models',
|
||||||
@@ -136,6 +120,36 @@ export default [
|
|||||||
access: 'canSeeUser',
|
access: 'canSeeUser',
|
||||||
component: './llmodels/user-models'
|
component: './llmodels/user-models'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'deployment',
|
||||||
|
path: '/models/deployments',
|
||||||
|
key: 'modelDeployments',
|
||||||
|
icon: 'icon-rocket-launch1',
|
||||||
|
selectedIcon: 'icon-rocket-launch-fill',
|
||||||
|
defaultIcon: 'icon-rocket-launch1',
|
||||||
|
access: 'canSeeOrgAdmin',
|
||||||
|
component: './llmodels/index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'routes',
|
||||||
|
path: '/models/routes',
|
||||||
|
key: 'routes',
|
||||||
|
icon: 'icon-captive_portal',
|
||||||
|
selectedIcon: 'icon-captive_portal',
|
||||||
|
defaultIcon: 'icon-captive_portal',
|
||||||
|
access: 'canSeeOrgAdmin',
|
||||||
|
component: './model-routes/index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'providers',
|
||||||
|
path: '/models/providers',
|
||||||
|
key: 'modelProviders',
|
||||||
|
icon: 'icon-extension-outline',
|
||||||
|
selectedIcon: 'icon-extension-filled',
|
||||||
|
defaultIcon: 'icon-extension-outline',
|
||||||
|
access: 'canSeeOrgAdmin',
|
||||||
|
component: './maas-provider/index'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'benchmark',
|
name: 'benchmark',
|
||||||
path: '/models/benchmark',
|
path: '/models/benchmark',
|
||||||
@@ -143,7 +157,7 @@ export default [
|
|||||||
icon: 'icon-speed',
|
icon: 'icon-speed',
|
||||||
selectedIcon: 'icon-speed-filled',
|
selectedIcon: 'icon-speed-filled',
|
||||||
defaultIcon: 'icon-speed',
|
defaultIcon: 'icon-speed',
|
||||||
access: 'canSeeAdmin',
|
access: 'canSeeOrgAdmin',
|
||||||
component: './benchmark/index'
|
component: './benchmark/index'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -153,9 +167,92 @@ export default [
|
|||||||
icon: 'icon-speed',
|
icon: 'icon-speed',
|
||||||
selectedIcon: 'icon-speed-filled',
|
selectedIcon: 'icon-speed-filled',
|
||||||
defaultIcon: 'icon-speed',
|
defaultIcon: 'icon-speed',
|
||||||
access: 'canSeeAdmin',
|
access: 'canSeeOrgAdmin',
|
||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
component: './benchmark/details'
|
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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'gpuService',
|
||||||
|
path: '/gpu-service',
|
||||||
|
key: 'gpuService',
|
||||||
|
access: 'canSeeGpuService',
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/gpu-service',
|
||||||
|
redirect: '/gpu-service/instances'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'instances',
|
||||||
|
path: '/gpu-service/instances',
|
||||||
|
key: 'gpuServiceList',
|
||||||
|
icon: 'icon-cloud-outlined',
|
||||||
|
selectedIcon: 'icon-cloud-filled',
|
||||||
|
defaultIcon: 'icon-cloud-outlined',
|
||||||
|
component: './gpu-service/instances'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'templates',
|
||||||
|
path: '/gpu-service/templates',
|
||||||
|
key: 'gpuServiceTemplates',
|
||||||
|
icon: 'icon-instance-template-outlined',
|
||||||
|
selectedIcon: 'icon-instance-template-filled',
|
||||||
|
defaultIcon: 'icon-instance-template-outlined',
|
||||||
|
component: './gpu-service/templates'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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-types'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'publicKeys',
|
||||||
|
path: '/gpu-service/public-keys',
|
||||||
|
key: 'gpuServicePublicKeys',
|
||||||
|
icon: 'icon-ssh-outlined',
|
||||||
|
selectedIcon: 'icon-ssh-filled',
|
||||||
|
defaultIcon: 'icon-ssh-outlined',
|
||||||
|
component: './gpu-service/public-keys'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -163,12 +260,22 @@ export default [
|
|||||||
name: 'resources',
|
name: 'resources',
|
||||||
path: '/resources',
|
path: '/resources',
|
||||||
key: 'resources',
|
key: 'resources',
|
||||||
access: 'canSeeAdmin',
|
access: 'canSeeOrgAdmin',
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/resources',
|
path: '/resources',
|
||||||
redirect: '/resources/workers'
|
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/detail', '/resources/clusters/create']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'workers',
|
name: 'workers',
|
||||||
path: '/resources/workers',
|
path: '/resources/workers',
|
||||||
@@ -188,67 +295,63 @@ export default [
|
|||||||
component: './resources/components/gpus'
|
component: './resources/components/gpus'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'backendsList',
|
name: 'credentials',
|
||||||
path: '/resources/backends',
|
path: '/resources/credentials',
|
||||||
key: 'backendsList',
|
key: 'credentials',
|
||||||
icon: 'icon-backend',
|
icon: 'icon-credential-outline',
|
||||||
selectedIcon: 'icon-backend-filled',
|
selectedIcon: 'icon-credential-filled',
|
||||||
defaultIcon: 'icon-backend',
|
defaultIcon: 'icon-credential-outline',
|
||||||
access: 'canSeeAdmin',
|
component: './cluster-management/credentials'
|
||||||
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: 'canSeeAdmin',
|
|
||||||
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',
|
name: 'clusterDetail',
|
||||||
path: '/cluster-management/clusters/detail',
|
path: '/resources/clusters/detail',
|
||||||
key: 'clusterDetail',
|
key: 'clusterDetail',
|
||||||
icon: 'icon-cluster2-outline',
|
icon: 'icon-cluster2-outline',
|
||||||
selectedIcon: 'icon-cluster2-filled',
|
selectedIcon: 'icon-cluster2-filled',
|
||||||
defaultIcon: 'icon-cluster2-outline',
|
defaultIcon: 'icon-cluster2-outline',
|
||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
component: './cluster-management/cluster-detail'
|
component: './cluster-management/cluster-detail'
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'credentials',
|
// Cross-resource consumption (tokens + GPU/CPU instances + storage).
|
||||||
path: '/cluster-management/credentials',
|
// A folder so it matches the other top-level groups; more usage views can
|
||||||
key: 'credentials',
|
// graduate in here later.
|
||||||
icon: 'icon-credential-outline',
|
name: 'billingAndUsage',
|
||||||
selectedIcon: 'icon-credential-filled',
|
path: '/usage',
|
||||||
defaultIcon: 'icon-credential-outline',
|
key: 'usageGroup',
|
||||||
component: './cluster-management/credentials'
|
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'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -256,12 +359,25 @@ export default [
|
|||||||
name: 'accessControl',
|
name: 'accessControl',
|
||||||
path: '/access-control',
|
path: '/access-control',
|
||||||
key: 'accessControl',
|
key: 'accessControl',
|
||||||
access: 'canSeeAdmin',
|
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/access-control',
|
path: '/access-control',
|
||||||
redirect: '/access-control/users'
|
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',
|
name: 'users',
|
||||||
path: '/access-control/users',
|
path: '/access-control/users',
|
||||||
@@ -269,7 +385,17 @@ export default [
|
|||||||
icon: 'icon-users',
|
icon: 'icon-users',
|
||||||
selectedIcon: 'icon-users-filled',
|
selectedIcon: 'icon-users-filled',
|
||||||
defaultIcon: 'icon-users',
|
defaultIcon: 'icon-users',
|
||||||
|
access: 'canSeeAdmin',
|
||||||
component: './users'
|
component: './users'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'apikeys',
|
||||||
|
path: '/access-control/api-keys',
|
||||||
|
key: 'apikeys',
|
||||||
|
selectedIcon: 'icon-key-filled',
|
||||||
|
icon: 'icon-key',
|
||||||
|
defaultIcon: 'icon-key',
|
||||||
|
component: './api-keys'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -285,8 +411,8 @@ export default [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'profile',
|
name: 'profile',
|
||||||
path: '/profile',
|
path: '/preferences',
|
||||||
key: 'profile',
|
key: 'preferences',
|
||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
component: './profile',
|
component: './profile',
|
||||||
icon: 'User'
|
icon: 'User'
|
||||||
@@ -307,3 +433,5 @@ export default [
|
|||||||
component: './404'
|
component: './404'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export default applyRouteExtensions(baseRoutes);
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
const child_process = require('child_process');
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
export const getBranchInfo = () => {
|
export const getBranchInfo = () => {
|
||||||
const latestCommit = child_process
|
// git may be absent (source archive, bare container) or this tree may
|
||||||
.execSync('git rev-parse HEAD')
|
// 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()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
const versionTag = child_process
|
} catch {
|
||||||
.execSync(`git tag --contains ${latestCommit}`)
|
// Not a git checkout / git unavailable; rely on env overrides.
|
||||||
.toString()
|
}
|
||||||
.trim();
|
// Respect explicit GPUSTACK_UI_* overrides so a wrapping build that
|
||||||
return { version: versionTag || '', commitId: latestCommit.slice(0, 7) };
|
// 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)
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import importPlugin from 'eslint-plugin-import';
|
||||||
|
import reactPlugin from 'eslint-plugin-react';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import unusedImports from 'eslint-plugin-unused-imports';
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores([
|
||||||
|
'public/static/',
|
||||||
|
'dist',
|
||||||
|
'src/.umi/',
|
||||||
|
'src/.umi-production/',
|
||||||
|
'src/.umi-test/',
|
||||||
|
'src/components/iconfont/'
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx,js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
prettier
|
||||||
|
],
|
||||||
|
plugins: {
|
||||||
|
react: reactPlugin,
|
||||||
|
import: importPlugin,
|
||||||
|
'unused-imports': unusedImports
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect'
|
||||||
|
},
|
||||||
|
'import/resolver': {
|
||||||
|
node: true,
|
||||||
|
typescript: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
Global: 'readonly',
|
||||||
|
React: 'readonly',
|
||||||
|
JSX: 'readonly'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react/no-unstable-nested-components': 'warn',
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'no-undef': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
|
'@typescript-eslint/ban-types': 'off',
|
||||||
|
'@typescript-eslint/no-empty-object-type': 'off',
|
||||||
|
'@typescript-eslint/no-unnecessary-type-constraint': 'off',
|
||||||
|
'unused-imports/no-unused-imports': 'error',
|
||||||
|
'unused-imports/no-unused-vars': 'off',
|
||||||
|
'import/no-unresolved': 'off',
|
||||||
|
'import/no-duplicates': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'off',
|
||||||
|
'react-hooks/preserve-manual-memoization': 'off',
|
||||||
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
|
'react-hooks/refs': 'off',
|
||||||
|
'react-hooks/use-memo': 'off',
|
||||||
|
'react-hooks/immutability': 'off',
|
||||||
|
'no-unsafe-optional-chaining': 'off',
|
||||||
|
'no-empty': 'off',
|
||||||
|
'no-constant-condition': 'off',
|
||||||
|
'no-prototype-builtins': 'off',
|
||||||
|
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0, maxBOF: 0 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "jialin",
|
"author": "gpustack",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "max build",
|
"build": "max build",
|
||||||
"check:locales": "npx tsx ./src/locales/check.ts",
|
"check:locales": "node --import tsx ./src/locales/check.ts",
|
||||||
"dev": "max dev",
|
"dev": "max dev",
|
||||||
"format": "prettier --cache --write .",
|
"format": "prettier --cache --write .",
|
||||||
"postinstall": "max setup",
|
"postinstall": "max setup",
|
||||||
@@ -12,13 +12,12 @@
|
|||||||
"setup": "max setup",
|
"setup": "max setup",
|
||||||
"start": "npm run dev"
|
"start": "npm run dev"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
|
||||||
"immer": "^9.0.6"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
"@ant-design/pro-components": "3.1.0-0",
|
"@ant-design/pro-components": "3.1.0-0",
|
||||||
|
"@antv/g6": "^5.0.51",
|
||||||
"@braintree/sanitize-url": "^7.1.1",
|
"@braintree/sanitize-url": "^7.1.1",
|
||||||
|
"@gpustack/core-ui": "^1.0.35",
|
||||||
"@huggingface/gguf": "^0.1.7",
|
"@huggingface/gguf": "^0.1.7",
|
||||||
"@huggingface/hub": "^0.15.1",
|
"@huggingface/hub": "^0.15.1",
|
||||||
"@huggingface/tasks": "^0.11.6",
|
"@huggingface/tasks": "^0.11.6",
|
||||||
@@ -31,29 +30,26 @@
|
|||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"ahooks": "^3.8.5",
|
"ahooks": "^3.8.5",
|
||||||
"ansi-to-html": "^0.7.2",
|
"ansi-to-html": "^0.7.2",
|
||||||
"antd": "^6.1.2",
|
"antd": "^6.3.3",
|
||||||
"antd-style": "^3.6.2",
|
"antd-style": "^3.6.2",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"clipboard": "^2.0.11",
|
"clipboard": "^2.0.11",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"culori": "^4.0.2",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"driver.js": "^1.3.1",
|
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^5.5.1",
|
||||||
"epubjs": "^0.3.93",
|
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"has-ansi": "^5.0.1",
|
"has-ansi": "^5.0.1",
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.10.0",
|
||||||
"jdenticon": "^3.3.0",
|
"jdenticon": "^3.3.0",
|
||||||
"jotai": "^2.8.4",
|
"jotai": "^2.8.4",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"katex": "^0.16.21",
|
"katex": "^0.16.21",
|
||||||
"lamejs": "github:zhuker/lamejs",
|
"lamejs": "github:zhuker/lamejs",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mammoth": "^1.8.0",
|
|
||||||
"marked": "^14.1.0",
|
"marked": "^14.1.0",
|
||||||
"minimatch": "^3.1.2",
|
"minimatch": "^3.1.2",
|
||||||
"ml-dataset-iris": "^1.2.1",
|
"ml-dataset-iris": "^1.2.1",
|
||||||
@@ -63,7 +59,6 @@
|
|||||||
"numeral": "^2.0.6",
|
"numeral": "^2.0.6",
|
||||||
"overlayscrollbars": "^2.10.0",
|
"overlayscrollbars": "^2.10.0",
|
||||||
"overlayscrollbars-react": "^0.5.6",
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
"pdfjs-dist": "^4.7.76",
|
|
||||||
"query-string": "^9.0.0",
|
"query-string": "^9.0.0",
|
||||||
"rc-resize-observer": "^1.4.3",
|
"rc-resize-observer": "^1.4.3",
|
||||||
"rc-virtual-list": "^3.14.8",
|
"rc-virtual-list": "^3.14.8",
|
||||||
@@ -73,6 +68,7 @@
|
|||||||
"react-hotkeys-hook": "^4.5.0",
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^9.0.3",
|
||||||
|
"react-router-dom": "^6.30.3",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
@@ -80,26 +76,33 @@
|
|||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"simplebar-react": "^3.2.6",
|
"simplebar-react": "^3.2.6",
|
||||||
"styled-components": "^6.1.15",
|
"styled-components": "^6.1.15",
|
||||||
"tinycolor2": "^1.6.0",
|
|
||||||
"umi-presets-pro": "^2.0.3",
|
"umi-presets-pro": "^2.0.3",
|
||||||
"wavesurfer.js": "^7.8.8",
|
"wavesurfer.js": "^7.8.8"
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.58.1",
|
||||||
"@umijs/case-sensitive-paths-webpack-plugin": "^1.0.1",
|
"@umijs/case-sensitive-paths-webpack-plugin": "^1.0.1",
|
||||||
"@umijs/plugins": "^4.4.11",
|
"@umijs/plugins": "^4.4.11",
|
||||||
"babel-plugin-named-asset-import": "^0.3.8",
|
"babel-plugin-named-asset-import": "^0.3.8",
|
||||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||||
"compression-webpack-plugin": "^11.1.0",
|
"compression-webpack-plugin": "^11.1.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-unused-imports": "^3.2.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
|
"eslint-plugin-import": "^2.32.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"extract-css-loader": "^0.0.1",
|
"extract-css-loader": "^0.0.1",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
|
"globals": "^17.4.0",
|
||||||
"husky": "^9.0.11",
|
"husky": "^9.0.11",
|
||||||
"less-loader": "^12.2.0",
|
"less-loader": "^12.2.0",
|
||||||
"lint-staged": "^15.2.2",
|
"lint-staged": "^15.2.2",
|
||||||
@@ -111,10 +114,12 @@
|
|||||||
"prettier-plugin-two-style-order": "^1.0.1",
|
"prettier-plugin-two-style-order": "^1.0.1",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
|
"typescript-eslint": "^8.58.0",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"webpack-bundle-analyzer": "^4.10.2",
|
"webpack-bundle-analyzer": "^4.10.2",
|
||||||
"worker-loader": "^3.0.8"
|
"worker-loader": "^3.0.8"
|
||||||
},
|
},
|
||||||
|
"packageManager": "pnpm@9.3.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"elliptic": "^6.6.1"
|
"elliptic": "^6.6.1"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export default (api: IApi) => {
|
|||||||
const info = JSON.parse(process.env.VERSION || '{}');
|
const info = JSON.parse(process.env.VERSION || '{}');
|
||||||
const env = process.env.NODE_ENV;
|
const env = process.env.NODE_ENV;
|
||||||
|
|
||||||
|
$('html').attr('lang', 'en');
|
||||||
|
|
||||||
$('html').attr('data-env', env);
|
$('html').attr('data-env', env);
|
||||||
|
|
||||||
$('html').attr(
|
$('html').attr(
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const pnpmDir = path.resolve(__dirname, '../../../node_modules/.pnpm');
|
||||||
|
|
||||||
|
function findDir(prefix) {
|
||||||
|
return fs.readdirSync(pnpmDir).find((name) => name.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stylelintDir = findDir('stylelint@14.8.2');
|
||||||
|
if (!stylelintDir) {
|
||||||
|
console.error('Compatible stylelint@14.8.2 not found in workspace node_modules/.pnpm');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stylelintBin = path.join(
|
||||||
|
pnpmDir,
|
||||||
|
stylelintDir,
|
||||||
|
'node_modules/stylelint/bin/stylelint.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = spawnSync(process.execPath, [stylelintBin, ...process.argv.slice(2)], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Identity hook for build-time access-predicate extensions. Tooling may
|
||||||
|
// overwrite this file to widen predicates; the original is restored on
|
||||||
|
// cleanup. Mirrors `config/routes.extensions.ts`.
|
||||||
|
export type AccessPredicates = {
|
||||||
|
canSeeAdmin: boolean;
|
||||||
|
canSeeOrgAdmin: boolean;
|
||||||
|
canManageCurrentOrg: boolean;
|
||||||
|
canSeeUser: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
canLogin: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyAccessExtensions = <T extends AccessPredicates>(base: T): T =>
|
||||||
|
base;
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
export default (initialState: { currentUser?: Global.UserInfo }) => {
|
import { applyAccessExtensions } from './access.extensions';
|
||||||
const canSeeAdmin = !!(
|
|
||||||
|
export default (initialState: {
|
||||||
|
currentUser?: Global.UserInfo;
|
||||||
|
hasKubernetesCluster?: boolean;
|
||||||
|
hasResourceEvents?: boolean;
|
||||||
|
}) => {
|
||||||
|
const isPlatformAdmin = !!(
|
||||||
initialState &&
|
initialState &&
|
||||||
initialState.currentUser &&
|
initialState.currentUser &&
|
||||||
initialState.currentUser.is_admin
|
initialState.currentUser.is_admin
|
||||||
@@ -9,11 +15,42 @@ export default (initialState: { currentUser?: Global.UserInfo }) => {
|
|||||||
initialState.currentUser &&
|
initialState.currentUser &&
|
||||||
!initialState.currentUser.is_admin
|
!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;
|
||||||
|
|
||||||
return {
|
// Predicate roles, top-down by strictness:
|
||||||
canSeeAdmin,
|
// * `canSeeAdmin` — strictly platform admin (`users.is_admin`).
|
||||||
|
// Gates Users.
|
||||||
|
// * `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
|
||||||
|
// the caller is admin of it.
|
||||||
|
// Pass through `applyAccessExtensions` so build-time tooling can
|
||||||
|
// widen these without editing this file. Default is a no-op.
|
||||||
|
return applyAccessExtensions({
|
||||||
|
canSeeAdmin: isPlatformAdmin,
|
||||||
|
canSeeOrgAdmin: isPlatformAdmin,
|
||||||
|
canSeeGpuService:
|
||||||
|
isPlatformAdmin || hasKubernetesCluster !== false || hasResourceEvents,
|
||||||
|
canManageCurrentOrg: false,
|
||||||
canSeeUser,
|
canSeeUser,
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
canLogin: true
|
canLogin: true
|
||||||
};
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user';
|
import { userSettingsHelperAtom } from '@/atoms/settings';
|
||||||
|
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
|
||||||
import { setAtomStorage } from '@/atoms/utils';
|
import { setAtomStorage } from '@/atoms/utils';
|
||||||
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
|
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';
|
import { requestConfig } from '@/request-config';
|
||||||
import {
|
import {
|
||||||
queryCurrentUserState,
|
queryCurrentUserState,
|
||||||
@@ -9,14 +14,22 @@ import {
|
|||||||
} from '@/services/profile/apis';
|
} from '@/services/profile/apis';
|
||||||
import { fetchSystemConfig } from '@/services/system/query-system-config';
|
import { fetchSystemConfig } from '@/services/system/query-system-config';
|
||||||
import { isOnline } from '@/utils';
|
import { isOnline } from '@/utils';
|
||||||
|
import {
|
||||||
|
markInitialStateProbed,
|
||||||
|
probeAccessFlags
|
||||||
|
} from '@/utils/access-probes';
|
||||||
|
import { installTenantFetch } from '@/utils/install-fetch';
|
||||||
import {
|
import {
|
||||||
IS_FIRST_LOGIN,
|
IS_FIRST_LOGIN,
|
||||||
readState,
|
readState,
|
||||||
writeState
|
writeState
|
||||||
} from '@/utils/localstore/index';
|
} from '@/utils/localstore/index';
|
||||||
import { RequestConfig, history } from '@umijs/max';
|
import '@gpustack/core-ui/style.css';
|
||||||
|
import { RequestConfig, history, request as umiRequest } from '@umijs/max';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
|
|
||||||
|
installTenantFetch();
|
||||||
|
|
||||||
// only for the first login and access from http://localhost
|
// only for the first login and access from http://localhost
|
||||||
|
|
||||||
const checkDefaultPage = async (userInfo: any) => {
|
const checkDefaultPage = async (userInfo: any) => {
|
||||||
@@ -33,9 +46,29 @@ const checkDefaultPage = async (userInfo: any) => {
|
|||||||
export async function getInitialState(): Promise<{
|
export async function getInitialState(): Promise<{
|
||||||
fetchUserInfo: () => Promise<Global.UserInfo>;
|
fetchUserInfo: () => Promise<Global.UserInfo>;
|
||||||
currentUser?: Global.UserInfo;
|
currentUser?: Global.UserInfo;
|
||||||
|
pluginData?: Record<string, any>;
|
||||||
|
hasKubernetesCluster?: boolean;
|
||||||
|
hasResourceEvents?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const { location } = history;
|
const { location } = history;
|
||||||
|
|
||||||
|
// In open-source builds the promise resolves immediately.
|
||||||
|
await enterprisePluginReady;
|
||||||
|
|
||||||
|
// initialize plugins and merge enterprise locales
|
||||||
|
let pluginData = {};
|
||||||
|
try {
|
||||||
|
pluginData = await GPUStackPluginManager.initialize({
|
||||||
|
request: umiRequest,
|
||||||
|
setUserSettings: (value) => setAtomStorage(userSettingsHelperAtom, value),
|
||||||
|
setStorageUserSettings: (value) =>
|
||||||
|
setAtomStorage(userSettingsHelperAtom, value),
|
||||||
|
defaultColorPrimary: COLOR_PRIMARY
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize plugins:', error);
|
||||||
|
}
|
||||||
|
|
||||||
const getUpdateCheck = async () => {
|
const getUpdateCheck = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await updateCheck();
|
const data = await updateCheck();
|
||||||
@@ -60,6 +93,36 @@ export async function getInitialState(): Promise<{
|
|||||||
getUpdateCheck();
|
getUpdateCheck();
|
||||||
fetchSystemConfig();
|
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;
|
return data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const data = error?.response?.data;
|
const data = error?.response?.data;
|
||||||
@@ -99,15 +162,29 @@ export async function getInitialState(): Promise<{
|
|||||||
getAppVersionInfo();
|
getAppVersionInfo();
|
||||||
|
|
||||||
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
|
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);
|
checkDefaultPage(userInfo);
|
||||||
return {
|
return {
|
||||||
fetchUserInfo,
|
fetchUserInfo,
|
||||||
currentUser: userInfo
|
currentUser: userInfo,
|
||||||
|
pluginData,
|
||||||
|
...accessFlags
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
fetchUserInfo
|
fetchUserInfo,
|
||||||
|
pluginData
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 5.2 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 |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 640 B |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 801 B |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<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 |
@@ -293,6 +293,10 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.color-white-tertiary {
|
.color-white-tertiary {
|
||||||
color: var(--color-white-tertiary);
|
color: var(--color-white-tertiary);
|
||||||
}
|
}
|
||||||
@@ -368,3 +372,15 @@ textarea:hover {
|
|||||||
.line-6 {
|
.line-6 {
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.align-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
.ant-layout-sider-children {
|
.ant-layout-sider-children {
|
||||||
border-inline: none;
|
border-inline: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
padding-inline-end: 0;
|
||||||
padding-block-end: 8px;
|
padding-block-end: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scroll-table {
|
||||||
|
.ant-table {
|
||||||
|
.ant-table-container {
|
||||||
|
.ant-table-body,
|
||||||
|
.ant-table-content {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-scrollbar-thumb) transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ export const fromClusterCreationAtom = atom(false);
|
|||||||
export const clusterSessionAtom = atom<{
|
export const clusterSessionAtom = atom<{
|
||||||
firstAddWorker: boolean;
|
firstAddWorker: boolean;
|
||||||
firstAddCluster: 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);
|
} | null>(null);
|
||||||
|
|
||||||
export const clusterDetailAtom = atom<ClusterListItem | null>(null);
|
export const clusterDetailAtom = atom<ClusterListItem | null>(null);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export const activeModelsAtom = atom<any[]>([]);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { getDefaultStore } from 'jotai';
|
||||||
|
import { atomWithStorage } from 'jotai/utils';
|
||||||
|
|
||||||
|
export interface PaginationState {
|
||||||
|
perPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paginationAtom = atomWithStorage<Record<string, any>>(
|
||||||
|
'paginationStatus',
|
||||||
|
{},
|
||||||
|
undefined,
|
||||||
|
{ getOnInit: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getPaginationStatus = (key: string) => {
|
||||||
|
if (!key) return {};
|
||||||
|
const store = getDefaultStore();
|
||||||
|
const cache = store.get(paginationAtom);
|
||||||
|
return cache[key] || {};
|
||||||
|
};
|
||||||
@@ -55,3 +55,10 @@ export const userSettingsHelperAtom = atom(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
export const hideModalTemporarilyAtom = atom<boolean>(false);
|
export const hideModalTemporarilyAtom = atom<boolean>(false);
|
||||||
|
|
||||||
|
export const collapsedMenuGroupsAtom = atomWithStorage<string[]>(
|
||||||
|
'collapsedMenuGroups',
|
||||||
|
[],
|
||||||
|
undefined,
|
||||||
|
{ getOnInit: true }
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export interface UsageTableData {
|
||||||
|
dataList: any[];
|
||||||
|
total: number;
|
||||||
|
loadend: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiKeysTableDataAtom = atom<UsageTableData>({
|
||||||
|
dataList: [],
|
||||||
|
total: 0,
|
||||||
|
loadend: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usersTableDataAtom = atom<UsageTableData>({
|
||||||
|
dataList: [],
|
||||||
|
total: 0,
|
||||||
|
loadend: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export const modelsTableDataAtom = atom<UsageTableData>({
|
||||||
|
dataList: [],
|
||||||
|
total: 0,
|
||||||
|
loadend: false
|
||||||
|
});
|
||||||
@@ -3,6 +3,15 @@ import { atomWithStorage } from 'jotai/utils';
|
|||||||
|
|
||||||
export const userAtom = atomWithStorage<any>('userInfo', null);
|
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<{
|
export const GPUStackVersionAtom = atom<{
|
||||||
version: string;
|
version: string;
|
||||||
git_commit: string;
|
git_commit: string;
|
||||||
@@ -23,7 +32,112 @@ export const UpdateCheckAtom = atom<{
|
|||||||
latest_version: ''
|
latest_version: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
export const initialPasswordAtom = atomWithStorage<string>(
|
export const initialPasswordAtom = atom<string>('');
|
||||||
'initialPassword',
|
|
||||||
''
|
// 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-{name}`` — because the GPU-instance / storage CRDs
|
||||||
|
// (worker.gpustack.ai/v1) are namespaced and the server-side admission
|
||||||
|
// 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
|
||||||
|
// plugin persists ``currentOrganizationId`` (numeric) when the user
|
||||||
|
// picks an Org via OrgSwitcher.
|
||||||
|
// 2. The cluster's own owner Org — used in the platform-admin "All"
|
||||||
|
// view, where the caller has no Org context but the resource still
|
||||||
|
// has to land in *some* Org's namespace.
|
||||||
|
// 3. ``gpustack-default`` as a last resort (first load before any
|
||||||
|
// cache is hydrated, or a cluster whose owner Org is missing from
|
||||||
|
// both caches).
|
||||||
|
//
|
||||||
|
// Called outside React (umi page utilities) so it reads localStorage
|
||||||
|
// directly rather than going through a Jotai hook. The org caches are
|
||||||
|
// kept fresh by the enterprise plugin's atomWithStorage atoms, and the
|
||||||
|
// OrgSwitcher reloads the page on switch so we don't need in-process
|
||||||
|
// reactivity here.
|
||||||
|
export const getCurrentOrgNamespace = (
|
||||||
|
clusterOwnerPrincipalId?: number | null
|
||||||
|
): string => {
|
||||||
|
return (
|
||||||
|
lookupOrgNamespace(getStoredCurrentOrgId()) ??
|
||||||
|
lookupOrgNamespace(clusterOwnerPrincipalId ?? null) ??
|
||||||
|
'gpustack-default'
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStoredCurrentOrgId = (): number | null => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('currentOrganizationId');
|
||||||
|
if (!raw) return null;
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
return typeof value === 'number' ? value : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Org caches the enterprise plugin persists. ``organizationList`` is
|
||||||
|
// 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 ``name`` might still cover the
|
||||||
|
// cluster-owner fallback.
|
||||||
|
const ORG_CACHE_KEYS = ['organizationList', 'allOrganizations'] as const;
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 CachedOrg[];
|
||||||
|
if (!Array.isArray(list)) continue;
|
||||||
|
const match = list.find((item) => String(item?.id) === target);
|
||||||
|
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(
|
const savedSettings = JSON.parse(
|
||||||
localStorage.getItem('userSettings') || '{}'
|
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(
|
localStorage.setItem(
|
||||||
'userSettings',
|
'userSettings',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...savedSettings,
|
...savedSettings,
|
||||||
hideAddResourceModal: false,
|
hideAddResourceModal: false
|
||||||
colorPrimary: undefined
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
import {
|
|
||||||
CheckCircleFilled,
|
|
||||||
LoadingOutlined,
|
|
||||||
WarningFilled
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { Typography } from 'antd';
|
|
||||||
import { createStyles } from 'antd-style';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import React from 'react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import OverlayScroller, { OverlayScrollerOptions } from '../overlay-scroller';
|
|
||||||
interface AlertInfoProps {
|
|
||||||
type: Global.MessageType;
|
|
||||||
message: React.ReactNode;
|
|
||||||
rows?: number;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
ellipsis?: boolean;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
contentStyle?: React.CSSProperties;
|
|
||||||
title?: React.ReactNode;
|
|
||||||
maxHeight?: number;
|
|
||||||
overlayScrollerProps?: OverlayScrollerOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useStyles = createStyles(({ token, css }) => {
|
|
||||||
return {
|
|
||||||
alertBlockInfo: css`
|
|
||||||
padding-block: 6px;
|
|
||||||
padding-inline: 10px 16px;
|
|
||||||
position: relative;
|
|
||||||
padding-left: 32px;
|
|
||||||
text-align: left;
|
|
||||||
border-radius: ${token.borderRadius}px;
|
|
||||||
margin: 0;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
.ant-typography {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.danger {
|
|
||||||
border-color: ${token.colorErrorBorder};
|
|
||||||
background-color: ${token.colorErrorBg};
|
|
||||||
}
|
|
||||||
|
|
||||||
&.warning {
|
|
||||||
border-color: ${token.colorWarningBorder};
|
|
||||||
background-color: ${token.colorWarningBg};
|
|
||||||
}
|
|
||||||
|
|
||||||
&.transition {
|
|
||||||
color: ${token.geekblue7};
|
|
||||||
background: ${token.geekblue1};
|
|
||||||
border-color: ${token.geekblue3};
|
|
||||||
}
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
border: 1px solid ${token.colorSuccess};
|
|
||||||
color: ${token.colorSuccessText};
|
|
||||||
background: ${token.colorSuccessBg};
|
|
||||||
|
|
||||||
.content.success {
|
|
||||||
font-weight: var(--font-weight-normal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
display: flex;
|
|
||||||
height: 32px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: ${token.borderRadius}px ${token.borderRadius}px 0 0;
|
|
||||||
|
|
||||||
.info-icon {
|
|
||||||
&.danger {
|
|
||||||
color: ${token.colorErrorText};
|
|
||||||
}
|
|
||||||
|
|
||||||
&.warning {
|
|
||||||
color: ${token.colorWarningText};
|
|
||||||
}
|
|
||||||
|
|
||||||
&.transition {
|
|
||||||
color: ${token.geekblue7};
|
|
||||||
}
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
color: ${token.colorSuccessText};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const TitleWrapper = styled.div`
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ant-color-text);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ContentWrapper = styled.div<{ $hasTitle: boolean }>`
|
|
||||||
word-break: break-word;
|
|
||||||
color: ${(props) =>
|
|
||||||
props.$hasTitle
|
|
||||||
? 'var(--ant-color-text-secondary)'
|
|
||||||
: 'var(--ant-color-text)'};
|
|
||||||
font-weight: var(--font-weight-500);
|
|
||||||
white-space: pre-line;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AlertInfo: React.FC<AlertInfoProps> = (props) => {
|
|
||||||
const {
|
|
||||||
message,
|
|
||||||
type,
|
|
||||||
rows = 1,
|
|
||||||
ellipsis,
|
|
||||||
style,
|
|
||||||
title,
|
|
||||||
contentStyle,
|
|
||||||
icon,
|
|
||||||
maxHeight = 86,
|
|
||||||
overlayScrollerProps = {}
|
|
||||||
} = props;
|
|
||||||
const { styles } = useStyles();
|
|
||||||
|
|
||||||
const renderIcon = () => {
|
|
||||||
if (type === 'transition') {
|
|
||||||
return <LoadingOutlined />;
|
|
||||||
}
|
|
||||||
if (type === 'success') {
|
|
||||||
return <CheckCircleFilled />;
|
|
||||||
}
|
|
||||||
return <WarningFilled />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{message ? (
|
|
||||||
<div
|
|
||||||
className={classNames(styles.alertBlockInfo, type)}
|
|
||||||
style={{ ...style }}
|
|
||||||
>
|
|
||||||
<Typography.Paragraph
|
|
||||||
ellipsis={
|
|
||||||
ellipsis ?? {
|
|
||||||
rows: rows,
|
|
||||||
tooltip: message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={classNames('title', type)}>
|
|
||||||
<span className={classNames('info-icon', type)}>
|
|
||||||
{icon ?? renderIcon()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{title && (
|
|
||||||
<TitleWrapper className="title-text">{title}</TitleWrapper>
|
|
||||||
)}
|
|
||||||
<OverlayScroller
|
|
||||||
maxHeight={maxHeight}
|
|
||||||
style={{ ...contentStyle }}
|
|
||||||
{...overlayScrollerProps}
|
|
||||||
>
|
|
||||||
<ContentWrapper
|
|
||||||
$hasTitle={!!title}
|
|
||||||
className={classNames('content', type)}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</ContentWrapper>
|
|
||||||
</OverlayScroller>
|
|
||||||
</Typography.Paragraph>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AlertInfo;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { WarningOutlined } from '@ant-design/icons';
|
|
||||||
import { Typography } from 'antd';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface AlertInfoProps {
|
|
||||||
type: 'danger' | 'warning';
|
|
||||||
message: string;
|
|
||||||
rows?: number;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
ellipsis?: boolean;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AlertInfo: React.FC<AlertInfoProps> = (props) => {
|
|
||||||
const { message, type, rows = 1, ellipsis, style } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{message ? (
|
|
||||||
<Typography.Paragraph
|
|
||||||
type={type}
|
|
||||||
ellipsis={
|
|
||||||
ellipsis !== undefined
|
|
||||||
? ellipsis
|
|
||||||
: {
|
|
||||||
rows: rows,
|
|
||||||
tooltip: message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
fontWeight: 400,
|
|
||||||
whiteSpace: 'pre-line',
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '2px 5px',
|
|
||||||
borderRadius: 'var(--border-radius-base)',
|
|
||||||
margin: 0,
|
|
||||||
backgroundColor: 'var(--ant-color-error-bg)',
|
|
||||||
...style
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WarningOutlined className="m-r-8" />
|
|
||||||
{message}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default React.memo(AlertInfo);
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
.canvas-wrap {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
image-rendering: crisp-edges;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroller-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import useResizeObserver from '@/components/logs-viewer/use-size';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import './index.less';
|
|
||||||
|
|
||||||
interface AudioAnimationProps {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
maxWidth?: number;
|
|
||||||
scaleFactor?: number;
|
|
||||||
maxBarCount?: number;
|
|
||||||
amplitude?: number;
|
|
||||||
fixedHeight?: boolean;
|
|
||||||
analyserData: {
|
|
||||||
data: Uint8Array;
|
|
||||||
analyser: any;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const AudioAnimation: React.FC<AudioAnimationProps> = (props) => {
|
|
||||||
const {
|
|
||||||
scaleFactor = 1.2,
|
|
||||||
maxBarCount = 128,
|
|
||||||
amplitude = 40,
|
|
||||||
maxWidth,
|
|
||||||
fixedHeight = true,
|
|
||||||
analyserData,
|
|
||||||
width: initialWidth,
|
|
||||||
height: initialHeight
|
|
||||||
} = props;
|
|
||||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
|
||||||
const animationId = React.useRef<number>(0);
|
|
||||||
const isScaled = React.useRef<boolean>(false);
|
|
||||||
const oscillationOffset = React.useRef(0);
|
|
||||||
const direction = React.useRef(1);
|
|
||||||
const scrollerRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const [width, setWidth] = useState(initialWidth);
|
|
||||||
const [height, setHeight] = useState(initialHeight);
|
|
||||||
const containerRef = React.useRef<any>(null);
|
|
||||||
|
|
||||||
const size = useResizeObserver(scrollerRef);
|
|
||||||
|
|
||||||
const calculateJitter = (
|
|
||||||
i: number,
|
|
||||||
timestamp: number,
|
|
||||||
baseHeight: number,
|
|
||||||
minJitter: number,
|
|
||||||
jitterAmplitude: number
|
|
||||||
) => {
|
|
||||||
//
|
|
||||||
const jitterFactor = Math.sin(timestamp / 200 + i) * 0.5 + 0.5;
|
|
||||||
const jitter =
|
|
||||||
minJitter +
|
|
||||||
jitterFactor * (jitterAmplitude - minJitter) * (baseHeight / maxBarCount);
|
|
||||||
return jitter;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startAudioVisualization = () => {
|
|
||||||
if (!canvasRef.current || !analyserData.data?.length) return;
|
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const canvasCtx = canvas.getContext('2d');
|
|
||||||
if (!canvasCtx) return;
|
|
||||||
|
|
||||||
const WIDTH = (canvas.width = width * 2);
|
|
||||||
const HEIGHT = (canvas.height = height * 2);
|
|
||||||
|
|
||||||
if (!isScaled.current) {
|
|
||||||
canvasCtx.scale(2, 2);
|
|
||||||
isScaled.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const barWidth = 4;
|
|
||||||
const barSpacing = 6;
|
|
||||||
const centerLine = Math.floor(HEIGHT / 2);
|
|
||||||
|
|
||||||
const jitterAmplitude = amplitude;
|
|
||||||
const minJitter = 10;
|
|
||||||
|
|
||||||
let lastFrameTime = 0;
|
|
||||||
|
|
||||||
const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT);
|
|
||||||
gradient.addColorStop(0, '#007BFF');
|
|
||||||
gradient.addColorStop(1, '#0069DA');
|
|
||||||
canvasCtx.fillStyle = gradient;
|
|
||||||
|
|
||||||
const draw = (timestamp: number) => {
|
|
||||||
const elapsed = timestamp - lastFrameTime;
|
|
||||||
if (elapsed < 16) {
|
|
||||||
animationId.current = requestAnimationFrame(draw);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastFrameTime = timestamp;
|
|
||||||
|
|
||||||
analyserData.analyser?.current?.getByteFrequencyData(analyserData.data);
|
|
||||||
canvasCtx.clearRect(0, 0, WIDTH, HEIGHT);
|
|
||||||
|
|
||||||
const barCount = Math.min(maxBarCount, analyserData.data.length);
|
|
||||||
const totalWidth = barCount * (barWidth + barSpacing) - barSpacing;
|
|
||||||
let x = WIDTH / 2 - totalWidth / 2 + oscillationOffset.current;
|
|
||||||
|
|
||||||
oscillationOffset.current += direction.current;
|
|
||||||
if (Math.abs(oscillationOffset.current) > 20) {
|
|
||||||
direction.current *= -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < barCount; i++) {
|
|
||||||
const baseHeight = Math.floor(analyserData.data[i] / 2) * scaleFactor;
|
|
||||||
|
|
||||||
const jitter = calculateJitter(
|
|
||||||
i,
|
|
||||||
timestamp,
|
|
||||||
baseHeight,
|
|
||||||
minJitter,
|
|
||||||
jitterAmplitude
|
|
||||||
);
|
|
||||||
const barHeight = baseHeight + Math.round(jitter);
|
|
||||||
|
|
||||||
const topY = Math.round(centerLine - barHeight / 2);
|
|
||||||
const bottomY = Math.round(centerLine + barHeight / 2);
|
|
||||||
|
|
||||||
canvasCtx.beginPath();
|
|
||||||
canvasCtx.moveTo(x, bottomY);
|
|
||||||
canvasCtx.lineTo(x, topY + 2);
|
|
||||||
canvasCtx.arcTo(x + barWidth, topY + 2, x + barWidth, bottomY, 2);
|
|
||||||
canvasCtx.lineTo(x + barWidth, bottomY);
|
|
||||||
canvasCtx.closePath();
|
|
||||||
canvasCtx.fill();
|
|
||||||
|
|
||||||
x += barWidth + barSpacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
animationId.current = requestAnimationFrame(draw);
|
|
||||||
};
|
|
||||||
|
|
||||||
draw(performance.now());
|
|
||||||
};
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (size) {
|
|
||||||
if (maxWidth) {
|
|
||||||
setWidth(Math.min(size.width, maxWidth));
|
|
||||||
} else {
|
|
||||||
setWidth(size?.width || 0);
|
|
||||||
}
|
|
||||||
if (!fixedHeight) {
|
|
||||||
setHeight(size?.height || 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [size, maxWidth]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canvasRef.current) return;
|
|
||||||
const clearCanvas = () => {
|
|
||||||
if (canvasRef.current) {
|
|
||||||
const ctx = canvasRef.current.getContext('2d');
|
|
||||||
if (ctx) ctx.clearRect(0, 0, width * 2, height * 2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!analyserData.data?.length || !analyserData.analyser?.current) {
|
|
||||||
clearCanvas();
|
|
||||||
cancelAnimationFrame(animationId.current);
|
|
||||||
animationId.current = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
startAudioVisualization();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(animationId.current);
|
|
||||||
clearCanvas();
|
|
||||||
};
|
|
||||||
}, [analyserData, width, height]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="scroller-wrapper"
|
|
||||||
ref={scrollerRef}
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="canvas-wrap"
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
>
|
|
||||||
<canvas ref={canvasRef} style={{ display: 'block' }}></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default React.memo(AudioAnimation);
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
const AudioWrapper = styled.div`
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AudioElement: React.FC<any> = (props) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<AudioWrapper>
|
|
||||||
<audio {...props} controls></audio>
|
|
||||||
</AudioWrapper>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AudioElement;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
export type AudioEvent =
|
|
||||||
| 'play'
|
|
||||||
| 'playing'
|
|
||||||
| 'pause'
|
|
||||||
| 'timeupdate'
|
|
||||||
| 'ended'
|
|
||||||
| 'loadedmetadata'
|
|
||||||
| 'audioprocess'
|
|
||||||
| 'canplay'
|
|
||||||
| 'ended'
|
|
||||||
| 'loadeddata'
|
|
||||||
| 'seeked'
|
|
||||||
| 'seeking'
|
|
||||||
| 'volumechange';
|
|
||||||
|
|
||||||
export interface AudioPlayerProps {
|
|
||||||
controls?: boolean;
|
|
||||||
autoplay?: boolean;
|
|
||||||
url: string;
|
|
||||||
speed?: number;
|
|
||||||
ref?: any;
|
|
||||||
height?: number;
|
|
||||||
width?: number;
|
|
||||||
duration?: number;
|
|
||||||
onPlay?: () => void;
|
|
||||||
onPlaying?: () => void;
|
|
||||||
onPause?: () => void;
|
|
||||||
onTimeUpdate?: () => void;
|
|
||||||
onEnded?: () => void;
|
|
||||||
onLoadedMetadata?: (duration: number) => void;
|
|
||||||
onAudioProcess?: (current: number) => void;
|
|
||||||
onCanPlay?: () => void;
|
|
||||||
onLoadedData?: () => void;
|
|
||||||
onSeeked?: () => void;
|
|
||||||
onSeeking?: () => void;
|
|
||||||
onVolumeChange?: () => void;
|
|
||||||
onReady?: (duration: number) => void;
|
|
||||||
onAnalyse?: (analyseData: any, frequencyBinCount: any) => void;
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
.player-wrap {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
// background-color: var(--ant-color-fill-quaternary);
|
|
||||||
border-radius: 6px;
|
|
||||||
|
|
||||||
.player-ui {
|
|
||||||
padding: 8px 16px;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-btn {
|
|
||||||
margin-inline: 30px;
|
|
||||||
height: 22px;
|
|
||||||
width: 22px;
|
|
||||||
|
|
||||||
.ant-btn {
|
|
||||||
height: 22px;
|
|
||||||
width: 22px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.backward,
|
|
||||||
.forward {
|
|
||||||
background: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.slider-inner {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
width: 52px;
|
|
||||||
text-align: right;
|
|
||||||
|
|
||||||
&.current {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
margin-inline: 10px;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.slider {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
line-height: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
align-self: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-slider-horizontal {
|
|
||||||
margin-block: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.speaker {
|
|
||||||
margin-left: 10px;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.volume-slider {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 30px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import { formatTime } from '@/utils/index';
|
|
||||||
import {
|
|
||||||
FastBackwardOutlined,
|
|
||||||
FastForwardOutlined,
|
|
||||||
PauseCircleFilled,
|
|
||||||
PlayCircleFilled
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useIntl } from '@umijs/max';
|
|
||||||
import { Button, Slider, Tooltip } from 'antd';
|
|
||||||
import { round } from 'lodash';
|
|
||||||
import React, {
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle
|
|
||||||
} from 'react';
|
|
||||||
import './index.less';
|
|
||||||
|
|
||||||
interface AudioPlayerProps {
|
|
||||||
autoplay?: boolean;
|
|
||||||
url: string;
|
|
||||||
speed?: number;
|
|
||||||
ref?: any;
|
|
||||||
name: string;
|
|
||||||
height?: number;
|
|
||||||
width?: number;
|
|
||||||
duration?: number;
|
|
||||||
extra?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const speedOptions = [
|
|
||||||
{ label: '1x', value: 1 },
|
|
||||||
{ label: '2x', value: 2 },
|
|
||||||
{ label: '3x', value: 3 },
|
|
||||||
{ label: '4x', value: 4 }
|
|
||||||
];
|
|
||||||
|
|
||||||
const speedConfig = {
|
|
||||||
min: 0.5,
|
|
||||||
max: 2,
|
|
||||||
step: 0.25
|
|
||||||
};
|
|
||||||
|
|
||||||
const AudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const { autoplay = false, speed: defaultSpeed = 1, extra } = props;
|
|
||||||
const audioRef = React.useRef<HTMLAudioElement>(null);
|
|
||||||
const [audioState, setAudioState] = React.useState<{
|
|
||||||
currentTime: number;
|
|
||||||
duration: number;
|
|
||||||
}>({
|
|
||||||
currentTime: 0,
|
|
||||||
duration: 0
|
|
||||||
});
|
|
||||||
const [playOn, setPlayOn] = React.useState<boolean>(false);
|
|
||||||
const [speakerOn, setSpeakerOn] = React.useState<boolean>(false);
|
|
||||||
const [volume, setVolume] = React.useState<number>(1);
|
|
||||||
const [speed, setSpeed] = React.useState<number>(defaultSpeed);
|
|
||||||
const timer = React.useRef<any>(null);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
play: () => {
|
|
||||||
audioRef.current?.play();
|
|
||||||
},
|
|
||||||
pause: () => {
|
|
||||||
audioRef.current?.pause();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
const handleShowVolume = useCallback(() => {
|
|
||||||
setSpeakerOn(!speakerOn);
|
|
||||||
}, [speakerOn]);
|
|
||||||
|
|
||||||
const handleSeepdChange = useCallback((value: number | string) => {
|
|
||||||
setSpeed(value as number);
|
|
||||||
audioRef.current!.playbackRate = value as number;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAudioOnPlay = useCallback(() => {
|
|
||||||
console.log('audio play');
|
|
||||||
timer.current = setInterval(() => {
|
|
||||||
setAudioState((prestate) => {
|
|
||||||
return {
|
|
||||||
currentTime: Math.ceil(audioRef.current?.currentTime || 0),
|
|
||||||
duration:
|
|
||||||
prestate.duration || Math.ceil(audioRef.current?.duration || 0)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (audioRef.current?.paused || audioRef.current?.ended) {
|
|
||||||
clearInterval(timer.current);
|
|
||||||
setPlayOn(false);
|
|
||||||
setAudioState((prestate: any) => {
|
|
||||||
return {
|
|
||||||
currentTime: audioRef.current?.ended ? 0 : prestate.currentTime,
|
|
||||||
duration: prestate.duration
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePlay = useCallback(() => {
|
|
||||||
setPlayOn(!playOn);
|
|
||||||
if (playOn) {
|
|
||||||
audioRef.current?.pause();
|
|
||||||
} else {
|
|
||||||
audioRef.current?.play();
|
|
||||||
}
|
|
||||||
}, [playOn]);
|
|
||||||
|
|
||||||
const handleFormatVolume = (val?: number) => {
|
|
||||||
if (val === undefined) {
|
|
||||||
return `${round(volume * 100)}%`;
|
|
||||||
}
|
|
||||||
return `${round(val * 100)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVolumeChange = useCallback((value: number) => {
|
|
||||||
audioRef.current!.volume = round(value, 2);
|
|
||||||
setVolume(round(value, 2));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const initPlayerConfig = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current!.volume = volume;
|
|
||||||
audioRef.current!.playbackRate = speed;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoadedMetadata = useCallback(
|
|
||||||
(data: any) => {
|
|
||||||
const duration = Math.ceil(audioRef.current?.duration || 0);
|
|
||||||
setAudioState({
|
|
||||||
currentTime: 0,
|
|
||||||
duration:
|
|
||||||
duration && duration !== Infinity ? duration : props.duration || 0
|
|
||||||
});
|
|
||||||
setPlayOn(autoplay);
|
|
||||||
},
|
|
||||||
[autoplay, props.duration]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCurrentChange = useCallback((val: number) => {
|
|
||||||
audioRef.current!.currentTime = val;
|
|
||||||
setAudioState((prestate) => {
|
|
||||||
return {
|
|
||||||
currentTime: val,
|
|
||||||
duration: prestate.duration
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleReduceSpeed = () => {
|
|
||||||
setSpeed((pre) => {
|
|
||||||
if (pre - speedConfig.step < speedConfig.min) {
|
|
||||||
return speedConfig.min;
|
|
||||||
}
|
|
||||||
const next = pre - speedConfig.step;
|
|
||||||
audioRef.current!.playbackRate = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddSpeed = () => {
|
|
||||||
setSpeed((pre) => {
|
|
||||||
if (pre + speedConfig.step > speedConfig.max) {
|
|
||||||
return speedConfig.max;
|
|
||||||
}
|
|
||||||
const next = pre + speedConfig.step;
|
|
||||||
audioRef.current!.playbackRate = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnLoad = (e: any) => {
|
|
||||||
console.log('onload', e);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDownload = useCallback(() => {
|
|
||||||
const url = props.url || '';
|
|
||||||
const filename = props.name;
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
}, [props.url, props.name]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
initPlayerConfig();
|
|
||||||
}
|
|
||||||
}, [audioRef.current]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
clearInterval(timer.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="player-wrap" style={{ width: props.width || '100%' }}>
|
|
||||||
<div className="player-ui">
|
|
||||||
<div className="play-content">
|
|
||||||
<div className="progress-bar">
|
|
||||||
<span className="file-name">{props.name}</span>
|
|
||||||
<div className="slider">
|
|
||||||
{/* <span className="time current">
|
|
||||||
{' '}
|
|
||||||
{formatTime(audioState.currentTime)}
|
|
||||||
</span> */}
|
|
||||||
<div className="slider-inner">
|
|
||||||
<Slider
|
|
||||||
tooltip={{ open: false }}
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
styles={{
|
|
||||||
rail: {
|
|
||||||
// height: 6
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
max={audioState.duration}
|
|
||||||
value={audioState.currentTime}
|
|
||||||
onChange={handleCurrentChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* <span className="time">{formatTime(audioState.duration)}</span> */}
|
|
||||||
</div>
|
|
||||||
<div className="controls">
|
|
||||||
<div className="audio-control flex-center">
|
|
||||||
<span className="time current">
|
|
||||||
{' '}
|
|
||||||
{formatTime(audioState.currentTime)}
|
|
||||||
</span>
|
|
||||||
<Tooltip
|
|
||||||
title={intl.formatMessage({
|
|
||||||
id: 'playground.audio.button.slow'
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
className="backward"
|
|
||||||
disabled={
|
|
||||||
speed === speedConfig.min || speed < speedConfig.min
|
|
||||||
}
|
|
||||||
onClick={handleReduceSpeed}
|
|
||||||
>
|
|
||||||
<FastBackwardOutlined className="font-size-20" />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<span className="play-btn">
|
|
||||||
<Button
|
|
||||||
size="middle"
|
|
||||||
type="text"
|
|
||||||
onClick={handlePlay}
|
|
||||||
disabled={!audioState?.duration}
|
|
||||||
icon={
|
|
||||||
!playOn ? (
|
|
||||||
<PlayCircleFilled
|
|
||||||
style={{ fontSize: '22px' }}
|
|
||||||
></PlayCircleFilled>
|
|
||||||
) : (
|
|
||||||
<PauseCircleFilled
|
|
||||||
style={{ fontSize: '22px' }}
|
|
||||||
></PauseCircleFilled>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></Button>
|
|
||||||
</span>
|
|
||||||
<Tooltip
|
|
||||||
title={intl.formatMessage({
|
|
||||||
id: 'playground.audio.button.fast'
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
className="forward"
|
|
||||||
disabled={
|
|
||||||
speed === speedConfig.max || speed > speedConfig.max
|
|
||||||
}
|
|
||||||
onClick={handleAddSpeed}
|
|
||||||
>
|
|
||||||
<FastForwardOutlined className="font-size-20" />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<span className="time">{formatTime(audioState.duration)}</span>
|
|
||||||
</div>
|
|
||||||
{extra}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<audio
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
autoPlay={autoplay}
|
|
||||||
src={props.url}
|
|
||||||
ref={audioRef}
|
|
||||||
preload="metadata"
|
|
||||||
style={{ opacity: 0, position: 'absolute', left: '-9999px' }}
|
|
||||||
onPlay={handleAudioOnPlay}
|
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
|
||||||
></audio>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default React.memo(AudioPlayer);
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import React, {
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef
|
|
||||||
} from 'react';
|
|
||||||
import { AudioPlayerProps } from './config/type';
|
|
||||||
|
|
||||||
const RawAudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
|
||||||
const { autoplay = false } = props;
|
|
||||||
const audioRef = React.useRef<HTMLAudioElement>(null);
|
|
||||||
|
|
||||||
// =================== audio context ======================
|
|
||||||
const audioContext = useRef<any>(null);
|
|
||||||
const analyser = useRef<any>(null);
|
|
||||||
const dataArray = useRef<any>(null);
|
|
||||||
// ========================================================
|
|
||||||
|
|
||||||
const initAudioContext = useCallback(() => {
|
|
||||||
audioContext.current = new (window.AudioContext ||
|
|
||||||
window.webkitAudioContext)();
|
|
||||||
|
|
||||||
analyser.current = audioContext.current.createAnalyser();
|
|
||||||
analyser.current.fftSize = 512;
|
|
||||||
dataArray.current = new Uint8Array(analyser.current.frequencyBinCount);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const generateVisualData = useCallback(() => {
|
|
||||||
const source = audioContext.current.createMediaElementSource(
|
|
||||||
audioRef.current
|
|
||||||
);
|
|
||||||
source.connect(analyser.current);
|
|
||||||
analyser.current.connect(audioContext.current.destination);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const initEnvents = () => {
|
|
||||||
if (!audioRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('complete', () => {});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('play', () => {
|
|
||||||
props.onAnalyse?.(dataArray.current, analyser);
|
|
||||||
props.onPlay?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('pause', () => {
|
|
||||||
props.onAnalyse?.(dataArray.current, analyser);
|
|
||||||
props.onPause?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('timeupdate', () => {
|
|
||||||
props.onTimeUpdate?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('ended', () => {
|
|
||||||
props.onEnded?.();
|
|
||||||
});
|
|
||||||
// add all other events
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('canplay', () => {
|
|
||||||
props.onCanPlay?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('loadeddata', () => {
|
|
||||||
initEnvents();
|
|
||||||
if (!audioContext.current) {
|
|
||||||
initAudioContext();
|
|
||||||
generateVisualData();
|
|
||||||
}
|
|
||||||
props.onLoadedData?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('seeked', () => {
|
|
||||||
props.onSeeked?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('seeking', () => {
|
|
||||||
props.onSeeking?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('volumechange', () => {
|
|
||||||
props.onVolumeChange?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('audioprocess', () => {
|
|
||||||
const current = audioRef.current?.currentTime || 0;
|
|
||||||
props.onAudioProcess?.(current);
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('playing', () => {
|
|
||||||
props.onPlaying?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('loadedmetadata', () => {
|
|
||||||
const duration = audioRef.current?.duration || 0;
|
|
||||||
props.onLoadedMetadata?.(duration);
|
|
||||||
props.onReady?.(duration);
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('ended', () => {
|
|
||||||
props.onEnded?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
audioRef.current.addEventListener('loadeddata', () => {
|
|
||||||
props.onLoadedData?.();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
play: () => {
|
|
||||||
audioRef.current?.play();
|
|
||||||
},
|
|
||||||
pause: () => {
|
|
||||||
audioRef.current?.pause();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
console.log('audioRef.current', audioRef.current, props.url);
|
|
||||||
initEnvents();
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (audioContext.current) {
|
|
||||||
audioContext.current.close();
|
|
||||||
}
|
|
||||||
// remove all events
|
|
||||||
|
|
||||||
audioRef.current?.removeEventListener('play', () => {});
|
|
||||||
audioRef.current?.removeEventListener('pause', () => {});
|
|
||||||
audioRef.current?.removeEventListener('timeupdate', () => {});
|
|
||||||
audioRef.current?.removeEventListener('ended', () => {});
|
|
||||||
audioRef.current?.removeEventListener('canplay', () => {});
|
|
||||||
audioRef.current?.removeEventListener('loadeddata', () => {});
|
|
||||||
audioRef.current?.removeEventListener('seeked', () => {});
|
|
||||||
audioRef.current?.removeEventListener('seeking', () => {});
|
|
||||||
audioRef.current?.removeEventListener('volumechange', () => {});
|
|
||||||
audioRef.current?.removeEventListener('audioprocess', () => {});
|
|
||||||
audioRef.current?.removeEventListener('playing', () => {});
|
|
||||||
audioRef.current?.removeEventListener('loadedmetadata', () => {});
|
|
||||||
audioRef.current?.removeEventListener('ended', () => {});
|
|
||||||
audioRef.current?.removeEventListener('loadeddata', () => {});
|
|
||||||
};
|
|
||||||
}, [audioRef.current]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<audio
|
|
||||||
controls
|
|
||||||
autoPlay={autoplay}
|
|
||||||
src={props.url}
|
|
||||||
ref={audioRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: '-9999px',
|
|
||||||
opacity: 0
|
|
||||||
}}
|
|
||||||
preload="metadata"
|
|
||||||
></audio>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default RawAudioPlayer;
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
import { formatTime } from '@/utils/index';
|
|
||||||
import { DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
|
|
||||||
import { useIntl } from '@umijs/max';
|
|
||||||
import { Button, Dropdown, Slider, type MenuProps } from 'antd';
|
|
||||||
import { createStyles } from 'antd-style';
|
|
||||||
import { round } from 'lodash';
|
|
||||||
import React, {
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useMemo
|
|
||||||
} from 'react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import AutoTooltip from '../auto-tooltip';
|
|
||||||
import IconFont from '../icon-font';
|
|
||||||
|
|
||||||
type ActionItem = 'download' | 'delete' | 'speed';
|
|
||||||
|
|
||||||
interface AudioPlayerProps {
|
|
||||||
autoplay?: boolean;
|
|
||||||
url: string;
|
|
||||||
speed?: number;
|
|
||||||
ref?: any;
|
|
||||||
name: string;
|
|
||||||
height?: number;
|
|
||||||
width?: number;
|
|
||||||
duration?: number;
|
|
||||||
actions?: ActionItem[];
|
|
||||||
onDelete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SliderWrapper = styled.div`
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
.ant-slider {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.time {
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const useStyles = createStyles(({ css, token }) => {
|
|
||||||
// @ts-ignore
|
|
||||||
const isDarkMode = token.darkMode as boolean;
|
|
||||||
return {
|
|
||||||
wrapper: css`
|
|
||||||
position: relative;
|
|
||||||
min-width: 360px;
|
|
||||||
height: 54px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: 8px 10px;
|
|
||||||
background-color: ${isDarkMode
|
|
||||||
? 'var(--ant-color-fill-secondary)'
|
|
||||||
: '#F1F3F4'};
|
|
||||||
border-radius: 28px;
|
|
||||||
.inner {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
flex: 1;
|
|
||||||
gap: 8px;
|
|
||||||
.slider {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1;
|
|
||||||
.ant-slider {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
&:hover {
|
|
||||||
.ant-slider-handle {
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&:focus-within {
|
|
||||||
.ant-slider-handle {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-slider-handle {
|
|
||||||
opacity: 0;
|
|
||||||
&::before {
|
|
||||||
background-color: var(--ant-color-bg-spotlight);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
&::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const sliderStyles = {
|
|
||||||
rail: {
|
|
||||||
borderRadius: '4px',
|
|
||||||
backgroundColor: 'var(--ant-color-fill-secondary)'
|
|
||||||
},
|
|
||||||
track: {
|
|
||||||
borderRadius: '4px',
|
|
||||||
backgroundColor: 'var(--ant-color-bg-spotlight)'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const speedOptions = [
|
|
||||||
{ label: '1x', value: 1 },
|
|
||||||
{ label: '2x', value: 2 },
|
|
||||||
{ label: '3x', value: 3 },
|
|
||||||
{ label: '4x', value: 4 }
|
|
||||||
];
|
|
||||||
|
|
||||||
const speedConfig = {
|
|
||||||
min: 0.5,
|
|
||||||
max: 2,
|
|
||||||
step: 0.25
|
|
||||||
};
|
|
||||||
|
|
||||||
const AudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const { styles } = useStyles();
|
|
||||||
const {
|
|
||||||
autoplay = false,
|
|
||||||
speed: defaultSpeed = 1,
|
|
||||||
actions = ['delete'],
|
|
||||||
name,
|
|
||||||
onDelete
|
|
||||||
} = props;
|
|
||||||
const audioRef = React.useRef<HTMLAudioElement>(null);
|
|
||||||
const [audioState, setAudioState] = React.useState<{
|
|
||||||
currentTime: number;
|
|
||||||
duration: number;
|
|
||||||
}>({
|
|
||||||
currentTime: 0,
|
|
||||||
duration: 0
|
|
||||||
});
|
|
||||||
console.log('audioState', name);
|
|
||||||
const [playOn, setPlayOn] = React.useState<boolean>(false);
|
|
||||||
const [speakerOn, setSpeakerOn] = React.useState<boolean>(false);
|
|
||||||
const [volume, setVolume] = React.useState<number>(1);
|
|
||||||
const [speed, setSpeed] = React.useState<number>(defaultSpeed);
|
|
||||||
const timer = React.useRef<any>(null);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
play: () => {
|
|
||||||
audioRef.current?.play();
|
|
||||||
},
|
|
||||||
pause: () => {
|
|
||||||
audioRef.current?.pause();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
const handleShowVolume = useCallback(() => {
|
|
||||||
setSpeakerOn(!speakerOn);
|
|
||||||
}, [speakerOn]);
|
|
||||||
|
|
||||||
const handleSeepdChange = useCallback((value: number | string) => {
|
|
||||||
setSpeed(value as number);
|
|
||||||
audioRef.current!.playbackRate = value as number;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAudioOnPlay = useCallback(() => {
|
|
||||||
timer.current = setInterval(() => {
|
|
||||||
setAudioState((prestate) => {
|
|
||||||
return {
|
|
||||||
currentTime: Math.ceil(audioRef.current?.currentTime || 0),
|
|
||||||
duration:
|
|
||||||
prestate.duration || Math.ceil(audioRef.current?.duration || 0)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (audioRef.current?.paused || audioRef.current?.ended) {
|
|
||||||
clearInterval(timer.current);
|
|
||||||
setPlayOn(false);
|
|
||||||
setAudioState((prestate: any) => {
|
|
||||||
return {
|
|
||||||
currentTime: audioRef.current?.ended ? 0 : prestate.currentTime,
|
|
||||||
duration: prestate.duration
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePlay = useCallback(() => {
|
|
||||||
setPlayOn(!playOn);
|
|
||||||
if (playOn) {
|
|
||||||
audioRef.current?.pause();
|
|
||||||
} else {
|
|
||||||
audioRef.current?.play();
|
|
||||||
}
|
|
||||||
}, [playOn]);
|
|
||||||
|
|
||||||
const handleFormatVolume = (val?: number) => {
|
|
||||||
if (val === undefined) {
|
|
||||||
return `${round(volume * 100)}%`;
|
|
||||||
}
|
|
||||||
return `${round(val * 100)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVolumeChange = useCallback((value: number) => {
|
|
||||||
audioRef.current!.volume = round(value, 2);
|
|
||||||
setVolume(round(value, 2));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const initPlayerConfig = () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current!.volume = volume;
|
|
||||||
audioRef.current!.playbackRate = speed;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoadedMetadata = useCallback(
|
|
||||||
(data: any) => {
|
|
||||||
const duration = Math.ceil(audioRef.current?.duration || 0);
|
|
||||||
setAudioState({
|
|
||||||
currentTime: 0,
|
|
||||||
duration:
|
|
||||||
duration && duration !== Infinity ? duration : props.duration || 0
|
|
||||||
});
|
|
||||||
setPlayOn(autoplay);
|
|
||||||
},
|
|
||||||
[autoplay, props.duration]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCurrentChange = useCallback((val: number) => {
|
|
||||||
audioRef.current!.currentTime = val;
|
|
||||||
setAudioState((prestate) => {
|
|
||||||
return {
|
|
||||||
currentTime: val,
|
|
||||||
duration: prestate.duration
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleReduceSpeed = () => {
|
|
||||||
setSpeed((pre) => {
|
|
||||||
if (pre - speedConfig.step < speedConfig.min) {
|
|
||||||
return speedConfig.min;
|
|
||||||
}
|
|
||||||
const next = pre - speedConfig.step;
|
|
||||||
audioRef.current!.playbackRate = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddSpeed = () => {
|
|
||||||
setSpeed((pre) => {
|
|
||||||
if (pre + speedConfig.step > speedConfig.max) {
|
|
||||||
return speedConfig.max;
|
|
||||||
}
|
|
||||||
const next = pre + speedConfig.step;
|
|
||||||
audioRef.current!.playbackRate = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnLoad = (e: any) => {
|
|
||||||
console.log('onload', e);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDownload = useCallback(() => {
|
|
||||||
const url = props.url || '';
|
|
||||||
const filename = props.name;
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename || 'audio.mp3'; // Default filename
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
}, [props.url, props.name]);
|
|
||||||
|
|
||||||
const items: MenuProps['items'] = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: 'download',
|
|
||||||
label: intl.formatMessage({ id: 'common.button.download' }),
|
|
||||||
icon: <DownloadOutlined />,
|
|
||||||
onClick: onDownload
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'speed',
|
|
||||||
label: intl.formatMessage({ id: 'playground.params.speed' }),
|
|
||||||
icon: <IconFont type="icon-play-speed"></IconFont>,
|
|
||||||
children: speedOptions.map((item) => ({
|
|
||||||
key: item.value,
|
|
||||||
label: item.label,
|
|
||||||
onClick: () => handleSeepdChange(item.value)
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'delete',
|
|
||||||
label: intl.formatMessage({ id: 'common.button.delete' }),
|
|
||||||
icon: <DeleteOutlined />,
|
|
||||||
danger: true,
|
|
||||||
onClick: () => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
audioRef.current.src = '';
|
|
||||||
audioRef.current.load();
|
|
||||||
}
|
|
||||||
setAudioState({ currentTime: 0, duration: 0 });
|
|
||||||
setPlayOn(false);
|
|
||||||
onDelete?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
].filter((item) => actions.includes(item.key as ActionItem));
|
|
||||||
}, [actions, intl, onDownload, onDelete, handleSeepdChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
initPlayerConfig();
|
|
||||||
}
|
|
||||||
}, [audioRef.current]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
clearInterval(timer.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.wrapper}
|
|
||||||
style={{
|
|
||||||
width: props.width || '100%',
|
|
||||||
height: props.height || '60px',
|
|
||||||
position: 'relative'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="inner">
|
|
||||||
<Button
|
|
||||||
size="middle"
|
|
||||||
type="text"
|
|
||||||
onClick={handlePlay}
|
|
||||||
shape="circle"
|
|
||||||
disabled={!audioState?.duration}
|
|
||||||
icon={
|
|
||||||
!playOn ? (
|
|
||||||
<IconFont
|
|
||||||
type="icon-playcircle-fill"
|
|
||||||
style={{ fontSize: '24px' }}
|
|
||||||
></IconFont>
|
|
||||||
) : (
|
|
||||||
<IconFont
|
|
||||||
type="icon-stopcircle-fill"
|
|
||||||
style={{ fontSize: '24px' }}
|
|
||||||
></IconFont>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></Button>
|
|
||||||
|
|
||||||
<div className="slider">
|
|
||||||
<div className="flex-center flex-between file-name">
|
|
||||||
<AutoTooltip ghost maxWidth={200}>
|
|
||||||
<span>{name}</span>
|
|
||||||
</AutoTooltip>
|
|
||||||
</div>
|
|
||||||
<SliderWrapper>
|
|
||||||
<span className="time">{formatTime(audioState.currentTime)}</span>
|
|
||||||
<Slider
|
|
||||||
tooltip={{ open: false }}
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
styles={sliderStyles}
|
|
||||||
max={audioState.duration}
|
|
||||||
value={audioState.currentTime}
|
|
||||||
onChange={handleCurrentChange}
|
|
||||||
/>
|
|
||||||
</SliderWrapper>
|
|
||||||
</div>
|
|
||||||
<Dropdown menu={{ items }} trigger={['click']}>
|
|
||||||
<Button
|
|
||||||
icon={<IconFont type="icon-more"></IconFont>}
|
|
||||||
type="text"
|
|
||||||
size="middle"
|
|
||||||
shape="circle"
|
|
||||||
></Button>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
<audio
|
|
||||||
crossOrigin="anonymous"
|
|
||||||
autoPlay={autoplay}
|
|
||||||
src={props.url}
|
|
||||||
ref={audioRef}
|
|
||||||
preload="metadata"
|
|
||||||
style={{ opacity: 0, position: 'absolute', left: '-9999px' }}
|
|
||||||
onPlay={handleAudioOnPlay}
|
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
|
||||||
></audio>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default React.memo(AudioPlayer);
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
.toolbar-wrapper {
|
|
||||||
padding: 0 24px;
|
|
||||||
color: rgba(255, 255, 255, 65%);
|
|
||||||
font-size: 16px;
|
|
||||||
background-color: rgba(0, 0, 0, 10%);
|
|
||||||
border-radius: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-wrapper .anticon {
|
|
||||||
padding: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-wrapper .anticon[disabled] {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-wrapper .anticon:hover {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||