Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a857b93b7 |
@@ -1,143 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
---
|
|
||||||
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
-1
@@ -13,6 +13,6 @@
|
|||||||
.swc
|
.swc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
.claude/settings.local.json
|
.claude
|
||||||
/dist.zip
|
/dist.zip
|
||||||
.cache
|
.cache
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# React State and Request Patterns
|
||||||
|
|
||||||
|
These guidelines define preferred patterns for request handling, state updates, and side-effect management in React applications.
|
||||||
|
|
||||||
|
The primary goal is to keep data flow explicit, predictable, maintainable, and performant while avoiding unnecessary rerenders and effect-driven logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Avoid Effect-Driven Requests
|
||||||
|
|
||||||
|
Do not use request functions themselves as dependencies in `useEffect`.
|
||||||
|
|
||||||
|
Avoid patterns like:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Requests should be triggered explicitly by user actions or lifecycle entry points.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Form Requests Should Be Action-Driven
|
||||||
|
|
||||||
|
For form-related requests (such as loading `Select` options):
|
||||||
|
|
||||||
|
- Fetch data when the form is opened for the first time.
|
||||||
|
- If later requests depend on user interactions, trigger them directly inside the interaction handler.
|
||||||
|
- Do not rely on `useEffect` dependency changes to trigger requests.
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const handleOnChange = (value) => {
|
||||||
|
fetchData(value);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData(value);
|
||||||
|
}, [value]);
|
||||||
|
```
|
||||||
|
|
||||||
|
The action itself should control the request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Update Related States Together
|
||||||
|
|
||||||
|
If a single action updates multiple related states:
|
||||||
|
|
||||||
|
- Do not synchronize them through `useEffect`
|
||||||
|
- Do not derive them indirectly through `useMemo`
|
||||||
|
|
||||||
|
Instead, update all related states directly inside the action handler.
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const handleOnChange = (value) => {
|
||||||
|
setState1(...);
|
||||||
|
setState2(...);
|
||||||
|
buildState(...);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid implicit state synchronization chains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Group Strongly Related State
|
||||||
|
|
||||||
|
If multiple states are always updated together:
|
||||||
|
|
||||||
|
- Do not split them into multiple `useState` calls.
|
||||||
|
- Prefer a single state object.
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [state, setState] = useState({
|
||||||
|
state1: ...,
|
||||||
|
state2: ...,
|
||||||
|
state3: ...,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This reduces unnecessary rerenders and keeps state transitions predictable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Prefer Explicit State Flow
|
||||||
|
|
||||||
|
Avoid chaining business logic through multiple `useEffect` hooks.
|
||||||
|
|
||||||
|
Keep:
|
||||||
|
|
||||||
|
- request execution
|
||||||
|
- state updates
|
||||||
|
- derived calculations
|
||||||
|
|
||||||
|
close to the triggering action whenever possible.
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const handleAction = () => {
|
||||||
|
fetchData();
|
||||||
|
setTableData(...);
|
||||||
|
setSelectedRow(...);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Over:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useEffect(() => {
|
||||||
|
buildTable();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateSelection();
|
||||||
|
}, [tableData]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Avoid Premature Memoization
|
||||||
|
|
||||||
|
Do not use `useMemo` or `useCallback` unless there is a confirmed rendering or computation bottleneck.
|
||||||
|
|
||||||
|
Overusing memoization:
|
||||||
|
|
||||||
|
- increases complexity
|
||||||
|
- makes state flow harder to understand
|
||||||
|
- may introduce stale dependency issues
|
||||||
|
|
||||||
|
Prefer simple and explicit logic first.
|
||||||
|
|
||||||
|
Optimize only when necessary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Keep Request Logic Predictable
|
||||||
|
|
||||||
|
A user interaction should clearly show:
|
||||||
|
|
||||||
|
- what request is triggered
|
||||||
|
- which states are updated
|
||||||
|
- how the UI changes
|
||||||
|
|
||||||
|
Avoid indirect update chains caused by dependency-driven effects.
|
||||||
|
|
||||||
|
The code should make the request and update flow easy to trace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Prefer Action-Driven Architecture
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
- action-driven updates
|
||||||
|
- explicit handlers
|
||||||
|
- localized state transitions
|
||||||
|
|
||||||
|
Over:
|
||||||
|
|
||||||
|
- effect-driven synchronization
|
||||||
|
- cross-hook implicit updates
|
||||||
|
- reactive chains between states
|
||||||
|
|
||||||
|
The triggering action should remain the primary source of truth for UI updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Form
|
||||||
|
|
||||||
|
Form-specific patterns that build on the rules above. The theme: keep cascading selections (pick A → derive B → write form) on a single, predictable path.
|
||||||
|
|
||||||
|
## 1. No Fallback for Derived Selection
|
||||||
|
|
||||||
|
When "pick A then auto-pick B", match by rule and return `undefined` if no match — let the corresponding form field stay empty.
|
||||||
|
|
||||||
|
Do not silently fall back to `list[0]` or another default. A fallback hides data issues and tricks the user into thinking they have a valid selection.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const findB = (key, list) =>
|
||||||
|
key ? list.find((x) => x.key === key) : undefined;
|
||||||
|
```
|
||||||
|
|
||||||
|
For form fields, prefer clearing with `undefined` over `''`. With Ant Design, `undefined` restores the placeholder; `''` is treated as a real value.
|
||||||
|
|
||||||
|
## 2. Async Race Protection
|
||||||
|
|
||||||
|
For fetches triggered by a lifecycle entry (e.g., modal open), tag each invocation with a session ref. Discard stale results if the session has rotated (the modal was closed and re-opened) by the time the response arrives.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const sessionRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
sessionRef.current += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = ++sessionRef.current;
|
||||||
|
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
|
||||||
|
if (sessionRef.current !== session) return;
|
||||||
|
applySelection(as.items[0], findB(as.items[0].key, bs.items));
|
||||||
|
});
|
||||||
|
}, [open]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Reference Template
|
||||||
|
|
||||||
|
A typical form with two cascading selectors backed by a single shared state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Selection = { a?: string; b?: number };
|
||||||
|
|
||||||
|
const [selection, setSelection] = useState<Selection>({});
|
||||||
|
const sessionRef = useRef(0);
|
||||||
|
|
||||||
|
const findB = (key, list) =>
|
||||||
|
key ? list.find((x) => x.key === key) : undefined;
|
||||||
|
|
||||||
|
// Single atomic write: state + form together.
|
||||||
|
const applySelection = (a, b) => {
|
||||||
|
setSelection({ a: a.name, b: b?.id });
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
field: b?.field,
|
||||||
|
spec: { ...currentSpec, ...b?.spec }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger 1: modal opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
sessionRef.current++;
|
||||||
|
setSelection({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = ++sessionRef.current;
|
||||||
|
Promise.all([fetchA(), fetchB()]).then(([as, bs]) => {
|
||||||
|
if (sessionRef.current !== session) return;
|
||||||
|
const first = as.items[0];
|
||||||
|
applySelection(first, findB(first.key, bs.items));
|
||||||
|
});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Trigger 2: user picks A
|
||||||
|
const handleAChange = (a) => {
|
||||||
|
applySelection(a, findB(a.key, listB));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger 3: user picks B
|
||||||
|
const handleBChange = (b) => {
|
||||||
|
setSelection((prev) => ({ ...prev, b: b.id }));
|
||||||
|
form.current?.setFieldsValue({ ...b.fields });
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
# 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`.
|
|
||||||
|
|
||||||
# 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`.
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
## Create form table list
|
||||||
|
|
||||||
|
## Create a form
|
||||||
|
|
||||||
|
## StatusTag
|
||||||
|
|
||||||
|
Use `StatusTag` from `@gpustack/core-ui` for status display. Do not use `Tag` from `antd` directly.
|
||||||
|
|
||||||
|
1. Define the mapping from business status values to UI status values in the module's `config/index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { StatusMaps } from '@/config';
|
||||||
|
import { StatusType } from '@/config/types';
|
||||||
|
|
||||||
|
export const XxxStatusValueMap = {
|
||||||
|
Running: 'running',
|
||||||
|
Pending: 'pending',
|
||||||
|
Failed: 'failed'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const XxxStatusLabelMap: Record<string, string> = {
|
||||||
|
[XxxStatusValueMap.Running]: 'Running',
|
||||||
|
[XxxStatusValueMap.Pending]: 'Pending',
|
||||||
|
[XxxStatusValueMap.Failed]: 'Failed'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const status: Record<string, StatusType> = {
|
||||||
|
[XxxStatusValueMap.Running]: StatusMaps.success,
|
||||||
|
[XxxStatusValueMap.Pending]: StatusMaps.transitioning,
|
||||||
|
[XxxStatusValueMap.Failed]: StatusMaps.error
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. In table columns, pass only the UI status value and display text required by `StatusTag`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StatusTag
|
||||||
|
statusValue={{
|
||||||
|
status: status[value],
|
||||||
|
text: XxxStatusLabelMap[value] || value,
|
||||||
|
message: record.state_message
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`statusValue.status` must be a value mapped from `StatusMaps`, such as `success`, `transitioning`, `warning`, `error`, or `inactive`. Do not pass business status values such as `running` or `pending` directly.
|
||||||
@@ -851,6 +851,3 @@ body {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
padding-block: 8px;
|
padding-block: 8px;
|
||||||
}
|
}
|
||||||
.ant-select-multiple .ant-select-content {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|||||||
+33
-111
@@ -1,132 +1,54 @@
|
|||||||
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
|
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
|
||||||
import { clampChroma, formatHex, modeOklch, modeRgb, useMode } from 'culori/fn';
|
import { converter, modeHsl, modeRgb, useMode } from 'culori/fn';
|
||||||
import useUserSettings from './use-user-settings';
|
|
||||||
|
|
||||||
// Linear-style minimal/neutral palette: a TIGHT blue -> violet band in OKLCH
|
const toHsl = converter('hsl');
|
||||||
// (perceptually uniform), kept moderate-chroma so fills read as clean and gentle
|
|
||||||
// rather than candy-colored. We deliberately stay narrow and DON'T fan out to
|
const DEFAULT_BRAND_HUE = 211; // brand color
|
||||||
// green/magenta — in this aesthetic series are separated by LIGHTNESS, not by
|
const COOL_HUE_START = 180;
|
||||||
// spreading across the hue wheel. Every series (including the first) is generated
|
const COOL_HUE_END = 280;
|
||||||
// from this one ramp, so the whole palette stays in a single cohesive color
|
|
||||||
// family — no special high-saturation brand color that clashes with the rest.
|
|
||||||
const COOL_HUE_START = 250; // blue
|
|
||||||
const COOL_HUE_END = 315; // violet (stops short of magenta/pink, stays neutral-cool)
|
|
||||||
const COOL_HUE_RANGE = COOL_HUE_END - COOL_HUE_START;
|
const COOL_HUE_RANGE = COOL_HUE_END - COOL_HUE_START;
|
||||||
|
const GOLDEN_RATIO_CONJUGATE = 0.618033988749895;
|
||||||
|
|
||||||
/**
|
function colorStringToHue(input?: string): number | undefined {
|
||||||
* Vivid, distinct cool accents — for places that need a handful of "primary"
|
if (!input) return undefined;
|
||||||
* colors, one per card/section (e.g. the summary trend cards), NOT a stacked
|
const color = toHsl(input);
|
||||||
* multi-series palette. Every color is anchor-quality (bright + saturated) and
|
if (!color || typeof color.h !== 'number') return undefined;
|
||||||
* spread evenly across the blue→violet band, so the set reads as several equally
|
return color.h;
|
||||||
* strong primaries rather than one bold + several washed-out fills.
|
|
||||||
*/
|
|
||||||
export function useCoolAccents() {
|
|
||||||
useMode(modeRgb);
|
|
||||||
useMode(modeOklch);
|
|
||||||
|
|
||||||
const { isDarkTheme } = useUserSettings();
|
|
||||||
|
|
||||||
return useMemoizedFn((count: number): string[] => {
|
|
||||||
if (count <= 0) return [];
|
|
||||||
|
|
||||||
const l = isDarkTheme ? 0.62 : 0.64;
|
|
||||||
const c = isDarkTheme ? 0.16 : 0.2;
|
|
||||||
|
|
||||||
const out: string[] = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const t = count <= 1 ? 0 : i / (count - 1);
|
|
||||||
const hue = COOL_HUE_START + t * COOL_HUE_RANGE;
|
|
||||||
out.push(
|
|
||||||
formatHex(clampChroma({ mode: 'oklch', l, c, h: hue }, 'oklch'))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useCoolColors() {
|
export default function useCoolColors() {
|
||||||
|
// const brandHue = useMemo(
|
||||||
|
// () => colorStringToHue(userSettings.colorPrimary) ?? DEFAULT_BRAND_HUE,
|
||||||
|
// [userSettings.colorPrimary]
|
||||||
|
// );
|
||||||
useMode(modeRgb);
|
useMode(modeRgb);
|
||||||
useMode(modeOklch);
|
useMode(modeHsl);
|
||||||
|
|
||||||
const { isDarkTheme } = useUserSettings();
|
const brandHue = DEFAULT_BRAND_HUE;
|
||||||
|
|
||||||
return useMemoizedFn((count: number): string[] => {
|
return useMemoizedFn((count: number): string[] => {
|
||||||
if (count <= 0) return [];
|
if (count <= 0) return [];
|
||||||
|
|
||||||
// Moderate chroma: clean and crisp, but gentle (not neon). Too low reads as
|
const colors: string[] = [];
|
||||||
// muddy/dirty; too high reads as harsh. Dark mode a touch lower so fills
|
|
||||||
// stay calm against the dark canvas.
|
|
||||||
const baseChroma = isDarkTheme ? 0.085 : 0.12;
|
|
||||||
|
|
||||||
// First series is the "primary" anchor: SAME blue family (the bluest end of
|
for (let i = 0; i < count; i++) {
|
||||||
// the ramp, nearest the brand hue) but clearly brighter and more saturated —
|
let hue: number;
|
||||||
// a vivid, clean brand-blue that reads as the base color. The rest of the
|
|
||||||
// palette stays low-chroma, so the anchor pops as the primary while the
|
|
||||||
// family still feels cohesive.
|
|
||||||
const colors: string[] = [
|
|
||||||
formatHex(
|
|
||||||
clampChroma(
|
|
||||||
{
|
|
||||||
mode: 'oklch',
|
|
||||||
l: isDarkTheme ? 0.62 : 0.66,
|
|
||||||
c: isDarkTheme ? 0.16 : 0.2,
|
|
||||||
h: COOL_HUE_START
|
|
||||||
},
|
|
||||||
'oklch'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
];
|
|
||||||
|
|
||||||
const rest = count - 1;
|
if (i === 0) {
|
||||||
if (rest <= 0) return colors;
|
hue = brandHue - 5;
|
||||||
|
} else {
|
||||||
|
const offset = (i * GOLDEN_RATIO_CONJUGATE) % 1;
|
||||||
|
hue = COOL_HUE_START + offset * COOL_HUE_RANGE;
|
||||||
|
|
||||||
// Separation is driven by LIGHTNESS, not hue. The hue band is narrow, so as
|
if (Math.abs(hue - brandHue) < 5) {
|
||||||
// the count grows we add lightness TIERS — each tier reuses the same narrow
|
hue = (hue + 10) % COOL_HUE_END;
|
||||||
// hue ramp at a distinct lightness level, multiplying how many separable
|
}
|
||||||
// colors fit while keeping the whole palette in one cohesive family.
|
}
|
||||||
const tiers = rest <= 6 ? 1 : rest <= 12 ? 2 : 3;
|
|
||||||
const steps = Math.ceil(rest / tiers);
|
|
||||||
|
|
||||||
// Distinct lightness levels per tier count (index 0 → 2 tiers, 1 → 3 tiers).
|
const s = i === 0 ? 100 : 75 + (i % 3) * 5;
|
||||||
// Lighter, airier levels for a fresh/crisp feel; kept in the upper-mid range
|
const l = i === 0 ? 50 : 55 + (i % 2) * 5;
|
||||||
// so fills stay clean and legible.
|
|
||||||
const lightTiers = isDarkTheme
|
|
||||||
? [
|
|
||||||
[0.68, 0.5],
|
|
||||||
[0.72, 0.6, 0.48]
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
[0.82, 0.64],
|
|
||||||
[0.84, 0.72, 0.6]
|
|
||||||
];
|
|
||||||
// Single-tier light/dark zig-zag for the common small-count case.
|
|
||||||
const [lightLo, lightHi] = isDarkTheme ? [0.5, 0.68] : [0.64, 0.82];
|
|
||||||
|
|
||||||
for (let i = 0; i < rest; i++) {
|
colors.push(`hsl(${Math.round(hue)}, ${s}%, ${l}%)`);
|
||||||
// Cycle the tier on every step so consecutive series always differ in
|
|
||||||
// lightness — exactly where stacked bars are hardest to tell apart.
|
|
||||||
const tier = i % tiers;
|
|
||||||
const step = Math.floor(i / tiers);
|
|
||||||
|
|
||||||
// Even hue ramp across the narrow band. Each tier is offset by a fraction
|
|
||||||
// of a step so same-step colors in different tiers don't share a hue.
|
|
||||||
const t = steps <= 1 ? 0.5 : (step + tier / tiers) / steps;
|
|
||||||
const hue = COOL_HUE_START + t * COOL_HUE_RANGE;
|
|
||||||
|
|
||||||
// 1 tier (≤6 series): light/dark zig-zag. 2-3 tiers: the tier's level.
|
|
||||||
const lightness =
|
|
||||||
tiers === 1 ? (i % 2 ? lightLo : lightHi) : lightTiers[tiers - 2][tier];
|
|
||||||
|
|
||||||
// Clamp chroma into the sRGB gamut so values don't get distorted by a raw
|
|
||||||
// channel clip when serialized to hex.
|
|
||||||
colors.push(
|
|
||||||
formatHex(
|
|
||||||
clampChroma(
|
|
||||||
{ mode: 'oklch', l: lightness, c: baseChroma, h: hue },
|
|
||||||
'oklch'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return colors;
|
return colors;
|
||||||
|
|||||||
@@ -198,8 +198,6 @@ export default {
|
|||||||
'common.table.user': 'User',
|
'common.table.user': 'User',
|
||||||
'common.settings.instructions': 'Instructions',
|
'common.settings.instructions': 'Instructions',
|
||||||
'common.settings.language': 'Language',
|
'common.settings.language': 'Language',
|
||||||
'common.settings.language.tips':
|
|
||||||
'Set the display language for the interface.',
|
|
||||||
'common.delete.confirm':
|
'common.delete.confirm':
|
||||||
'Are you sure you want to delete the selected {type}?',
|
'Are you sure you want to delete the selected {type}?',
|
||||||
'common.delete.single.confirm':
|
'common.delete.single.confirm':
|
||||||
@@ -253,11 +251,6 @@ export default {
|
|||||||
'common.appearance.tips': 'Default follows system preference.',
|
'common.appearance.tips': 'Default follows system preference.',
|
||||||
'common.button.forgotpassword': 'Forgot password?',
|
'common.button.forgotpassword': 'Forgot password?',
|
||||||
'common.appearance.theme': 'Theme',
|
'common.appearance.theme': 'Theme',
|
||||||
'common.appearance.description':
|
|
||||||
'Customize how the interface looks on your device.',
|
|
||||||
'common.security': 'Security',
|
|
||||||
'common.security.description':
|
|
||||||
'Manage the password used to sign in to your account.',
|
|
||||||
'common.page.wentwrong': 'Something went wrong.',
|
'common.page.wentwrong': 'Something went wrong.',
|
||||||
'common.page.refresh.tips':
|
'common.page.refresh.tips':
|
||||||
'The page may need to be updated. Try refreshing it!',
|
'The page may need to be updated. Try refreshing it!',
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ export default {
|
|||||||
'gpuservice.publicKey': 'SSH Public Key',
|
'gpuservice.publicKey': 'SSH Public Key',
|
||||||
'gpuservice.publicKey.add': 'Add SSH Public Key',
|
'gpuservice.publicKey.add': 'Add SSH Public Key',
|
||||||
'gpuservice.publicKey.edit': 'Edit SSH Public Key',
|
'gpuservice.publicKey.edit': 'Edit SSH Public Key',
|
||||||
'gpuservice.publicKey.delete.tips':
|
|
||||||
'Deleting an SSH Public Key will not revoke access for existing attached Instances. To remove access, edit those Instances separately.',
|
|
||||||
'gpuservice.publicKey.filter.name': 'Search by name',
|
'gpuservice.publicKey.filter.name': 'Search by name',
|
||||||
'gpuservice.publicKey.label': 'SSH Public Key',
|
'gpuservice.publicKey.label': 'SSH Public Key',
|
||||||
'gpuservice.instance.ssh.enable': 'Enable SSH Access',
|
'gpuservice.instance.ssh.enable': 'Enable SSH Access',
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ export default {
|
|||||||
'users.password.modify.title': 'Modify Password',
|
'users.password.modify.title': 'Modify Password',
|
||||||
'users.password.modify.description':
|
'users.password.modify.description':
|
||||||
"For your account's security, please change your initial password.",
|
"For your account's security, please change your initial password.",
|
||||||
'users.password.modify.tips':
|
|
||||||
'Regularly updating your password helps keep your account secure.',
|
|
||||||
'users.password.confirm': 'Confirm New Password',
|
'users.password.confirm': 'Confirm New Password',
|
||||||
'users.password.confirm.empty': 'Please confirm the new password.',
|
'users.password.confirm.empty': 'Please confirm the new password.',
|
||||||
'users.password.confirm.error': 'The two passwords entered do not match.',
|
'users.password.confirm.error': 'The two passwords entered do not match.',
|
||||||
|
|||||||
@@ -199,7 +199,6 @@ export default {
|
|||||||
'common.table.user': 'ユーザー',
|
'common.table.user': 'ユーザー',
|
||||||
'common.settings.instructions': '手順',
|
'common.settings.instructions': '手順',
|
||||||
'common.settings.language': '言語',
|
'common.settings.language': '言語',
|
||||||
'common.settings.language.tips': 'インターフェースの表示言語を設定します。',
|
|
||||||
'common.delete.confirm': '選択した {type} を削除してもよろしいですか?',
|
'common.delete.confirm': '選択した {type} を削除してもよろしいですか?',
|
||||||
'common.delete.single.confirm':
|
'common.delete.single.confirm':
|
||||||
'<span style="font-size: 13px;font-weight: 700">{name}</span> を削除してもよろしいですか?',
|
'<span style="font-size: 13px;font-weight: 700">{name}</span> を削除してもよろしいですか?',
|
||||||
@@ -252,11 +251,6 @@ export default {
|
|||||||
'common.appearance.tips': 'Default follows system preference.',
|
'common.appearance.tips': 'Default follows system preference.',
|
||||||
'common.button.forgotpassword': 'Forgot password?',
|
'common.button.forgotpassword': 'Forgot password?',
|
||||||
'common.appearance.theme': 'Theme',
|
'common.appearance.theme': 'Theme',
|
||||||
'common.appearance.description':
|
|
||||||
'デバイス上でのインターフェースの表示をカスタマイズします。',
|
|
||||||
'common.security': 'セキュリティ',
|
|
||||||
'common.security.description':
|
|
||||||
'アカウントへのログインに使用するパスワードを管理します。',
|
|
||||||
'common.page.wentwrong': 'Something went wrong.',
|
'common.page.wentwrong': 'Something went wrong.',
|
||||||
'common.page.refresh.tips':
|
'common.page.refresh.tips':
|
||||||
'The page may need to be updated. Try refreshing it!',
|
'The page may need to be updated. Try refreshing it!',
|
||||||
|
|||||||
@@ -80,8 +80,6 @@ export default {
|
|||||||
'gpuservice.publicKey': 'SSH 公開鍵',
|
'gpuservice.publicKey': 'SSH 公開鍵',
|
||||||
'gpuservice.publicKey.add': 'SSH 公開鍵を追加',
|
'gpuservice.publicKey.add': 'SSH 公開鍵を追加',
|
||||||
'gpuservice.publicKey.edit': 'SSH 公開鍵を編集',
|
'gpuservice.publicKey.edit': 'SSH 公開鍵を編集',
|
||||||
'gpuservice.publicKey.delete.tips':
|
|
||||||
'SSH 公開鍵を削除しても、既にアタッチされているインスタンスのアクセス権は取り消されません。アクセス権を削除するには、対象のインスタンスを個別に編集してください。',
|
|
||||||
'gpuservice.publicKey.filter.name': '名前で検索',
|
'gpuservice.publicKey.filter.name': '名前で検索',
|
||||||
'gpuservice.publicKey.label': 'SSH 公開鍵',
|
'gpuservice.publicKey.label': 'SSH 公開鍵',
|
||||||
'gpuservice.instance.ssh.enable': 'SSH アクセスを有効化',
|
'gpuservice.instance.ssh.enable': 'SSH アクセスを有効化',
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ export default {
|
|||||||
'users.password.modify.title': 'パスワードを変更',
|
'users.password.modify.title': 'パスワードを変更',
|
||||||
'users.password.modify.description':
|
'users.password.modify.description':
|
||||||
'アカウントのセキュリティのため、初期パスワードを変更してください。',
|
'アカウントのセキュリティのため、初期パスワードを変更してください。',
|
||||||
'users.password.modify.tips':
|
|
||||||
'パスワードを定期的に更新すると、アカウントの安全を保てます。',
|
|
||||||
'users.password.confirm': '新しいパスワードを確認',
|
'users.password.confirm': '新しいパスワードを確認',
|
||||||
'users.password.confirm.empty': '新しいパスワードを確認してください。',
|
'users.password.confirm.empty': '新しいパスワードを確認してください。',
|
||||||
'users.password.confirm.error': '入力された2つのパスワードが一致しません。',
|
'users.password.confirm.error': '入力された2つのパスワードが一致しません。',
|
||||||
|
|||||||
@@ -197,7 +197,6 @@ export default {
|
|||||||
'common.table.user': 'Пользователь',
|
'common.table.user': 'Пользователь',
|
||||||
'common.settings.instructions': 'Инструкции',
|
'common.settings.instructions': 'Инструкции',
|
||||||
'common.settings.language': 'Язык',
|
'common.settings.language': 'Язык',
|
||||||
'common.settings.language.tips': 'Задайте язык отображения интерфейса.',
|
|
||||||
'common.delete.confirm': 'Вы уверены, что хотите удалить выбранный {type}?',
|
'common.delete.confirm': 'Вы уверены, что хотите удалить выбранный {type}?',
|
||||||
'common.delete.single.confirm':
|
'common.delete.single.confirm':
|
||||||
'Вы уверены, что хотите удалить <span style="font-size: 13px;font-weight: 700">{name}</span>?',
|
'Вы уверены, что хотите удалить <span style="font-size: 13px;font-weight: 700">{name}</span>?',
|
||||||
@@ -251,11 +250,6 @@ export default {
|
|||||||
'common.appearance.tips': 'По умолчанию соответствует системным настройкам.',
|
'common.appearance.tips': 'По умолчанию соответствует системным настройкам.',
|
||||||
'common.button.forgotpassword': 'Забыли пароль?',
|
'common.button.forgotpassword': 'Забыли пароль?',
|
||||||
'common.appearance.theme': 'Тема',
|
'common.appearance.theme': 'Тема',
|
||||||
'common.appearance.description':
|
|
||||||
'Настройте внешний вид интерфейса на вашем устройстве.',
|
|
||||||
'common.security': 'Безопасность',
|
|
||||||
'common.security.description':
|
|
||||||
'Управляйте паролем для входа в учётную запись.',
|
|
||||||
'common.page.wentwrong': 'Что-то пошло не так.',
|
'common.page.wentwrong': 'Что-то пошло не так.',
|
||||||
'common.page.refresh.tips':
|
'common.page.refresh.tips':
|
||||||
'Страница может нуждаться в обновлении. Попробуйте обновить её!',
|
'Страница может нуждаться в обновлении. Попробуйте обновить её!',
|
||||||
|
|||||||
@@ -84,8 +84,6 @@ export default {
|
|||||||
'gpuservice.publicKey': 'Открытый ключ SSH',
|
'gpuservice.publicKey': 'Открытый ключ SSH',
|
||||||
'gpuservice.publicKey.add': 'Добавить открытый ключ SSH',
|
'gpuservice.publicKey.add': 'Добавить открытый ключ SSH',
|
||||||
'gpuservice.publicKey.edit': 'Изменить открытый ключ SSH',
|
'gpuservice.publicKey.edit': 'Изменить открытый ключ SSH',
|
||||||
'gpuservice.publicKey.delete.tips':
|
|
||||||
'Удаление открытого ключа SSH не отзывает доступ для уже подключённых экземпляров. Чтобы удалить доступ, отредактируйте эти экземпляры отдельно.',
|
|
||||||
'gpuservice.publicKey.filter.name': 'Поиск по имени',
|
'gpuservice.publicKey.filter.name': 'Поиск по имени',
|
||||||
'gpuservice.publicKey.label': 'Открытый ключ SSH',
|
'gpuservice.publicKey.label': 'Открытый ключ SSH',
|
||||||
'gpuservice.instance.ssh.enable': 'Включить SSH-доступ',
|
'gpuservice.instance.ssh.enable': 'Включить SSH-доступ',
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ export default {
|
|||||||
'users.password.modify.title': 'Смена пароля',
|
'users.password.modify.title': 'Смена пароля',
|
||||||
'users.password.modify.description':
|
'users.password.modify.description':
|
||||||
'В целях безопасности измените первоначальный пароль.',
|
'В целях безопасности измените первоначальный пароль.',
|
||||||
'users.password.modify.tips':
|
|
||||||
'Регулярное обновление пароля помогает защитить вашу учётную запись.',
|
|
||||||
'users.password.confirm': 'Подтвердите новый пароль',
|
'users.password.confirm': 'Подтвердите новый пароль',
|
||||||
'users.password.confirm.empty': 'Подтвердите новый пароль',
|
'users.password.confirm.empty': 'Подтвердите новый пароль',
|
||||||
'users.password.confirm.error': 'Пароли не совпадают',
|
'users.password.confirm.error': 'Пароли не совпадают',
|
||||||
|
|||||||
@@ -199,7 +199,6 @@ export default {
|
|||||||
'common.table.user': 'Kullanıcı',
|
'common.table.user': 'Kullanıcı',
|
||||||
'common.settings.instructions': 'Talimatlar',
|
'common.settings.instructions': 'Talimatlar',
|
||||||
'common.settings.language': 'Dil',
|
'common.settings.language': 'Dil',
|
||||||
'common.settings.language.tips': 'Arayüzün görüntüleme dilini ayarlayın.',
|
|
||||||
'common.delete.confirm':
|
'common.delete.confirm':
|
||||||
'Seçili {type} öğesini silmek istediğinizden emin misiniz?',
|
'Seçili {type} öğesini silmek istediğinizden emin misiniz?',
|
||||||
'common.delete.single.confirm':
|
'common.delete.single.confirm':
|
||||||
@@ -255,11 +254,6 @@ export default {
|
|||||||
'common.appearance.tips': 'Varsayılan olarak sistem tercihini takip eder.',
|
'common.appearance.tips': 'Varsayılan olarak sistem tercihini takip eder.',
|
||||||
'common.button.forgotpassword': 'Şifrenizi mi unuttunuz?',
|
'common.button.forgotpassword': 'Şifrenizi mi unuttunuz?',
|
||||||
'common.appearance.theme': 'Tema',
|
'common.appearance.theme': 'Tema',
|
||||||
'common.appearance.description':
|
|
||||||
'Arayüzün cihazınızdaki görünümünü özelleştirin.',
|
|
||||||
'common.security': 'Güvenlik',
|
|
||||||
'common.security.description':
|
|
||||||
'Hesabınıza giriş yapmak için kullanılan parolayı yönetin.',
|
|
||||||
'common.page.wentwrong': 'Bir şeyler ters gitti.',
|
'common.page.wentwrong': 'Bir şeyler ters gitti.',
|
||||||
'common.page.refresh.tips':
|
'common.page.refresh.tips':
|
||||||
'Sayfanın güncellenmesi gerekebilir. Yenilemeyi deneyin!',
|
'Sayfanın güncellenmesi gerekebilir. Yenilemeyi deneyin!',
|
||||||
|
|||||||
@@ -80,8 +80,6 @@ export default {
|
|||||||
'gpuservice.publicKey': 'SSH Açık Anahtarı',
|
'gpuservice.publicKey': 'SSH Açık Anahtarı',
|
||||||
'gpuservice.publicKey.add': 'SSH Açık Anahtarı Ekle',
|
'gpuservice.publicKey.add': 'SSH Açık Anahtarı Ekle',
|
||||||
'gpuservice.publicKey.edit': 'SSH Açık Anahtarını Düzenle',
|
'gpuservice.publicKey.edit': 'SSH Açık Anahtarını Düzenle',
|
||||||
'gpuservice.publicKey.delete.tips':
|
|
||||||
'Bir SSH Açık Anahtarını silmek, mevcut bağlı Örneklerin erişimini iptal etmez. Erişimi kaldırmak için ilgili Örnekleri ayrı ayrı düzenleyin.',
|
|
||||||
'gpuservice.publicKey.filter.name': 'Ada göre ara',
|
'gpuservice.publicKey.filter.name': 'Ada göre ara',
|
||||||
'gpuservice.publicKey.label': 'SSH Açık Anahtarı',
|
'gpuservice.publicKey.label': 'SSH Açık Anahtarı',
|
||||||
'gpuservice.instance.ssh.enable': 'SSH Erişimini Etkinleştir',
|
'gpuservice.instance.ssh.enable': 'SSH Erişimini Etkinleştir',
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ export default {
|
|||||||
'users.password.modify.title': 'Şifreyi Değiştir',
|
'users.password.modify.title': 'Şifreyi Değiştir',
|
||||||
'users.password.modify.description':
|
'users.password.modify.description':
|
||||||
'Hesabınızın güvenliği için lütfen başlangıç şifrenizi değiştirin.',
|
'Hesabınızın güvenliği için lütfen başlangıç şifrenizi değiştirin.',
|
||||||
'users.password.modify.tips':
|
|
||||||
'Parolanızı düzenli olarak güncellemek hesabınızın güvenliğini korumaya yardımcı olur.',
|
|
||||||
'users.password.confirm': 'Yeni Şifreyi Onayla',
|
'users.password.confirm': 'Yeni Şifreyi Onayla',
|
||||||
'users.password.confirm.empty': 'Lütfen yeni şifreyi tekrar girin.',
|
'users.password.confirm.empty': 'Lütfen yeni şifreyi tekrar girin.',
|
||||||
'users.password.confirm.error': 'Girilen iki şifre eşleşmiyor.',
|
'users.password.confirm.error': 'Girilen iki şifre eşleşmiyor.',
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ export default {
|
|||||||
'common.table.user': '用户',
|
'common.table.user': '用户',
|
||||||
'common.settings.instructions': '操作指引',
|
'common.settings.instructions': '操作指引',
|
||||||
'common.settings.language': '语言',
|
'common.settings.language': '语言',
|
||||||
'common.settings.language.tips': '设置界面的显示语言。',
|
|
||||||
'common.delete.confirm': '确定删除选中的{type}吗?',
|
'common.delete.confirm': '确定删除选中的{type}吗?',
|
||||||
'common.delete.single.confirm':
|
'common.delete.single.confirm':
|
||||||
'确定删除以下内容?\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
|
'确定删除以下内容?\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
|
||||||
@@ -247,9 +246,6 @@ export default {
|
|||||||
'common.appearance.tips': '默认跟随系统设置',
|
'common.appearance.tips': '默认跟随系统设置',
|
||||||
'common.button.forgotpassword': '忘记密码?',
|
'common.button.forgotpassword': '忘记密码?',
|
||||||
'common.appearance.theme': '主题',
|
'common.appearance.theme': '主题',
|
||||||
'common.appearance.description': '自定义界面在您设备上的视觉表现。',
|
|
||||||
'common.security': '安全设置',
|
|
||||||
'common.security.description': '管理用于登录账户的密码。',
|
|
||||||
'common.page.wentwrong': '哎呀,出了点问题',
|
'common.page.wentwrong': '哎呀,出了点问题',
|
||||||
'common.page.refresh.tips': '页面似乎需要更新,刷新一下试试吧!',
|
'common.page.refresh.tips': '页面似乎需要更新,刷新一下试试吧!',
|
||||||
'common.tips.escape.disable': '请点击「取消」按钮或右上角 X 关闭窗口',
|
'common.tips.escape.disable': '请点击「取消」按钮或右上角 X 关闭窗口',
|
||||||
|
|||||||
@@ -75,8 +75,6 @@ export default {
|
|||||||
'gpuservice.publicKey': 'SSH 公钥',
|
'gpuservice.publicKey': 'SSH 公钥',
|
||||||
'gpuservice.publicKey.add': '添加 SSH 公钥',
|
'gpuservice.publicKey.add': '添加 SSH 公钥',
|
||||||
'gpuservice.publicKey.edit': '编辑 SSH 公钥',
|
'gpuservice.publicKey.edit': '编辑 SSH 公钥',
|
||||||
'gpuservice.publicKey.delete.tips':
|
|
||||||
'删除 SSH Public Key 不会撤销已挂载 GPU 实例的访问权限。如需移除访问权限,请分别编辑对应的 GPU 实例。',
|
|
||||||
'gpuservice.publicKey.filter.name': '按名称搜索',
|
'gpuservice.publicKey.filter.name': '按名称搜索',
|
||||||
'gpuservice.publicKey.label': 'SSH 公钥',
|
'gpuservice.publicKey.label': 'SSH 公钥',
|
||||||
'gpuservice.instance.ssh.enable': '启用 SSH 访问',
|
'gpuservice.instance.ssh.enable': '启用 SSH 访问',
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export default {
|
|||||||
'users.password.length': '长度在6至64个字符之间',
|
'users.password.length': '长度在6至64个字符之间',
|
||||||
'users.password.modify.title': '修改密码',
|
'users.password.modify.title': '修改密码',
|
||||||
'users.password.modify.description': '为了确保您的账户安全,请修改初始密码',
|
'users.password.modify.description': '为了确保您的账户安全,请修改初始密码',
|
||||||
'users.password.modify.tips': '定期更新密码有助于保护账户安全。',
|
|
||||||
'users.password.confirm': '确认新密码',
|
'users.password.confirm': '确认新密码',
|
||||||
'users.password.confirm.empty': '请确认新密码',
|
'users.password.confirm.empty': '请确认新密码',
|
||||||
'users.password.confirm.error': '两次输入的密码不一致',
|
'users.password.confirm.error': '两次输入的密码不一致',
|
||||||
|
|||||||
@@ -47,12 +47,7 @@ const APIKeyForm: React.FC<{
|
|||||||
|
|
||||||
<PluginExtraFields
|
<PluginExtraFields
|
||||||
name="CreateOrgScopeField"
|
name="CreateOrgScopeField"
|
||||||
context={{
|
context={{ action, allowPersonal: true }}
|
||||||
action,
|
|
||||||
allowPersonal: true,
|
|
||||||
allowGlobal: true,
|
|
||||||
globalLabelId: 'scope.global'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ export interface ListItem {
|
|||||||
masked_value?: string;
|
masked_value?: string;
|
||||||
user_id?: number;
|
user_id?: number;
|
||||||
user_name?: string;
|
user_name?: string;
|
||||||
// The owning principal — an Org, or a USER principal for a
|
// The owning principal — an Org, or a USER principal when the key
|
||||||
// personal-scope key, or NULL for an admin "All" mode key (no
|
// was created in someone's Personal Org. Read by the enterprise
|
||||||
// tenant pinning).
|
// plugin's Organization column in the admin All-org view.
|
||||||
owner_principal_id?: number | null;
|
owner_principal_id?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const ClusterDetailModal = () => {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'workers',
|
key: 'workers',
|
||||||
label: intl.formatMessage({ id: 'resources.nodes' }),
|
label: `Workers`,
|
||||||
icon: <IconFont type="icon-resources" />,
|
icon: <IconFont type="icon-resources" />,
|
||||||
children: (
|
children: (
|
||||||
<WorkerList clusterId={Number(id)} source="clusterDetail" />
|
<WorkerList clusterId={Number(id)} source="clusterDetail" />
|
||||||
@@ -68,7 +68,7 @@ const ClusterDetailModal = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'gpus',
|
key: 'gpus',
|
||||||
label: intl.formatMessage({ id: 'menu.resources.gpus' }),
|
label: `GPUs`,
|
||||||
icon: <IconFont type="icon-gpu1" />,
|
icon: <IconFont type="icon-gpu1" />,
|
||||||
children: <GPUList clusterId={Number(id)} source="clusterDetail" />
|
children: <GPUList clusterId={Number(id)} source="clusterDetail" />
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -194,11 +194,7 @@ const Clusters: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = useMemoizedFn((val: any, row: ListItem, item?: any) => {
|
const handleSelect = useMemoizedFn((val: any, row: ListItem) => {
|
||||||
if (item?.onClick) {
|
|
||||||
item.onClick(row);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (val === 'edit') {
|
if (val === 'edit') {
|
||||||
handleEditCluster(row);
|
handleEditCluster(row);
|
||||||
} else if (val === 'delete') {
|
} else if (val === 'delete') {
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import { useClusterDetail } from '../../services/use-cluster-detail';
|
|||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 146px;
|
height: 168px;
|
||||||
.left {
|
.left {
|
||||||
padding: 16px 0px;
|
padding: 16px 24px;
|
||||||
width: 124px;
|
width: 124px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -56,7 +56,7 @@ const Resources = styled.div`
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
margin-top: 24px;
|
margin-top: 16px;
|
||||||
.item {
|
.item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -134,6 +134,7 @@ const ClusterBasic: React.FC<{ clusterId: number }> = ({ clusterId }) => {
|
|||||||
)}
|
)}
|
||||||
</Title>
|
</Title>
|
||||||
}
|
}
|
||||||
|
layout="vertical"
|
||||||
items={items}
|
items={items}
|
||||||
/>
|
/>
|
||||||
<Resources>
|
<Resources>
|
||||||
|
|||||||
@@ -47,21 +47,10 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
|
|||||||
}
|
}
|
||||||
}, [clusterId]);
|
}, [clusterId]);
|
||||||
|
|
||||||
const generateStrokeColor = (percent: number) => {
|
|
||||||
if (percent <= 50) {
|
|
||||||
return 'var(--ant-color-success)';
|
|
||||||
}
|
|
||||||
if (percent <= 80) {
|
|
||||||
return 'var(--ant-color-warning)';
|
|
||||||
}
|
|
||||||
return 'var(--ant-color-error)';
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderStepsProgress = (
|
const renderStepsProgress = (
|
||||||
percent: number,
|
percent: number,
|
||||||
tag: { color: string; text: string }
|
tag: { color: string; text: string }
|
||||||
) => {
|
) => {
|
||||||
const strokeColor = generateStrokeColor(percent);
|
|
||||||
return (
|
return (
|
||||||
<Progress
|
<Progress
|
||||||
percent={percent}
|
percent={percent}
|
||||||
@@ -69,7 +58,6 @@ const ClusterSystemLoad: React.FC<{ clusterId: number }> = ({ clusterId }) => {
|
|||||||
size={50}
|
size={50}
|
||||||
strokeWidth={8}
|
strokeWidth={8}
|
||||||
showInfo={true}
|
showInfo={true}
|
||||||
strokeColor={strokeColor}
|
|
||||||
format={() => (
|
format={() => (
|
||||||
<Tag
|
<Tag
|
||||||
color={tag?.color || 'blue'}
|
color={tag?.color || 'blue'}
|
||||||
|
|||||||
@@ -24,18 +24,15 @@ import {
|
|||||||
ProviderValueMap
|
ProviderValueMap
|
||||||
} from '../config';
|
} from '../config';
|
||||||
import { ClusterListItem } from '../config/types';
|
import { ClusterListItem } from '../config/types';
|
||||||
|
|
||||||
const clusterActionList = [
|
const clusterActionList = [
|
||||||
{
|
{
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
label: 'common.button.edit',
|
label: 'common.button.edit',
|
||||||
order: 0,
|
|
||||||
icon: icons.EditOutlined
|
icon: icons.EditOutlined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'resources.metrics.details',
|
label: 'resources.metrics.details',
|
||||||
key: 'metrics',
|
key: 'metrics',
|
||||||
order: 10,
|
|
||||||
icon: (
|
icon: (
|
||||||
<span className="flex-center">
|
<span className="flex-center">
|
||||||
<GrafanaIcon style={{ width: 14, height: 14 }}></GrafanaIcon>
|
<GrafanaIcon style={{ width: 14, height: 14 }}></GrafanaIcon>
|
||||||
@@ -47,7 +44,6 @@ const clusterActionList = [
|
|||||||
label: 'resources.button.create',
|
label: 'resources.button.create',
|
||||||
provider: ProviderValueMap.Docker,
|
provider: ProviderValueMap.Docker,
|
||||||
locale: true,
|
locale: true,
|
||||||
order: 20,
|
|
||||||
icon: icons.DockerOutlined
|
icon: icons.DockerOutlined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,7 +51,6 @@ const clusterActionList = [
|
|||||||
label: 'clusters.button.register',
|
label: 'clusters.button.register',
|
||||||
provider: ProviderValueMap.Kubernetes,
|
provider: ProviderValueMap.Kubernetes,
|
||||||
locale: true,
|
locale: true,
|
||||||
order: 30,
|
|
||||||
icon: icons.KubernetesOutlined
|
icon: icons.KubernetesOutlined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,20 +58,16 @@ const clusterActionList = [
|
|||||||
label: 'clusters.button.addNodePool',
|
label: 'clusters.button.addNodePool',
|
||||||
provider: ProviderValueMap.DigitalOcean,
|
provider: ProviderValueMap.DigitalOcean,
|
||||||
locale: true,
|
locale: true,
|
||||||
order: 40,
|
|
||||||
icon: icons.Catalog1
|
icon: icons.Catalog1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'isDefault',
|
key: 'isDefault',
|
||||||
label: 'clusters.form.setDefault',
|
label: 'clusters.form.setDefault',
|
||||||
locale: true,
|
|
||||||
order: 50,
|
|
||||||
icon: icons.StarOutlined
|
icon: icons.StarOutlined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
label: 'common.button.delete',
|
label: 'common.button.delete',
|
||||||
order: 999,
|
|
||||||
icon: icons.DeleteOutlined,
|
icon: icons.DeleteOutlined,
|
||||||
props: {
|
props: {
|
||||||
danger: true
|
danger: true
|
||||||
@@ -85,7 +76,7 @@ const clusterActionList = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const useClusterColumns = (
|
const useClusterColumns = (
|
||||||
handleSelect: (val: string, record: ClusterListItem, item?: any) => void,
|
handleSelect: (val: string, record: ClusterListItem) => void,
|
||||||
onCellClick?: (record: ClusterListItem, dataIndex: string) => void
|
onCellClick?: (record: ClusterListItem, dataIndex: string) => void
|
||||||
): SealColumnProps[] => {
|
): SealColumnProps[] => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -97,15 +88,11 @@ const useClusterColumns = (
|
|||||||
// `clusterDetail.linkableName`. Without a plugin we render the
|
// `clusterDetail.linkableName`. Without a plugin we render the
|
||||||
// name as plain text (matches the pre-restore behaviour); with one
|
// name as plain text (matches the pre-restore behaviour); with one
|
||||||
// we use Typography.Link wired to the parent's `onCellClick`.
|
// we use Typography.Link wired to the parent's `onCellClick`.
|
||||||
|
const nameLinkable: boolean =
|
||||||
const { linkableName: nameLinkable, useGenerateActions } =
|
!!getGPUStackPlugin()?.clusterDetail?.linkableName;
|
||||||
getGPUStackPlugin()?.clusterDetail || {};
|
|
||||||
|
|
||||||
const actionList =
|
|
||||||
useGenerateActions?.({ actions: clusterActionList }) || clusterActionList;
|
|
||||||
|
|
||||||
const setActionsItems = (row: ClusterListItem) => {
|
const setActionsItems = (row: ClusterListItem) => {
|
||||||
return actionList.filter((item: any) => {
|
return clusterActionList.filter((item) => {
|
||||||
if (item.provider) {
|
if (item.provider) {
|
||||||
return item.provider === row.provider;
|
return item.provider === row.provider;
|
||||||
}
|
}
|
||||||
@@ -195,7 +182,7 @@ const useClusterColumns = (
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: intl.formatMessage({ id: 'dashboard.totalgpus' }),
|
title: intl.formatMessage({ id: 'menu.resources.gpus' }),
|
||||||
dataIndex: 'gpus',
|
dataIndex: 'gpus',
|
||||||
span: 2,
|
span: 2,
|
||||||
sorter: tableSorter(3),
|
sorter: tableSorter(3),
|
||||||
@@ -251,9 +238,7 @@ const useClusterColumns = (
|
|||||||
render: (value: string, record: ClusterListItem) => (
|
render: (value: string, record: ClusterListItem) => (
|
||||||
<DropdownButtons
|
<DropdownButtons
|
||||||
items={setActionsItems(record)}
|
items={setActionsItems(record)}
|
||||||
onSelect={(val: string, item: any) =>
|
onSelect={(val) => handleSelect(val, record)}
|
||||||
handleSelect(val, record, item)
|
|
||||||
}
|
|
||||||
></DropdownButtons>
|
></DropdownButtons>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,77 +225,29 @@ export const toUsagePieData = (
|
|||||||
return aggregateUsageByGroup(data, groupBy, metric);
|
return aggregateUsageByGroup(data, groupBy, metric);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UsageTokenMetric = 'input_tokens' | 'output_tokens';
|
export const toUsageRankData = (
|
||||||
|
|
||||||
export interface UsageTokenSeriesDef {
|
|
||||||
name: string;
|
|
||||||
key: UsageTokenMetric;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregates each group's input/output tokens into a stacked HBarChart shape,
|
|
||||||
// so a single bar shows prompt (input) and completion (output) tokens side by
|
|
||||||
// side. Rows sharing a group (e.g. the same user across dates) are summed, then
|
|
||||||
// ranked by combined tokens and capped at the top 10.
|
|
||||||
export const toUsageTokenBreakdownData = (
|
|
||||||
data: UsageBreakdownResponse | null | undefined,
|
data: UsageBreakdownResponse | null | undefined,
|
||||||
groupBy: UsageGroupBy,
|
groupBy: UsageGroupBy,
|
||||||
seriesDefs: UsageTokenSeriesDef[]
|
seriesName: string,
|
||||||
|
color: string
|
||||||
) => {
|
) => {
|
||||||
const itemMap = new Map<string, { total: number; values: number[] }>();
|
const items = aggregateUsageByGroup(data, groupBy, 'total_tokens');
|
||||||
const items = getUsageResponseItems(data);
|
const names = items.map((item) => item.name);
|
||||||
|
|
||||||
items.forEach((item: BreakdownItem) => {
|
|
||||||
const name = buildUsageLabel(item, groupBy);
|
|
||||||
const entry = itemMap.get(name) || {
|
|
||||||
total: 0,
|
|
||||||
values: seriesDefs.map(() => 0)
|
|
||||||
};
|
|
||||||
|
|
||||||
seriesDefs.forEach((def, index) => {
|
|
||||||
const value = Number((item as any)?.[def.key] ?? 0);
|
|
||||||
entry.values[index] += value;
|
|
||||||
entry.total += value;
|
|
||||||
});
|
|
||||||
|
|
||||||
itemMap.set(name, entry);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ranked = Array.from(itemMap.entries())
|
|
||||||
.filter(([, entry]) => entry.total > 0)
|
|
||||||
.sort((a, b) => b[1].total - a[1].total)
|
|
||||||
.slice(0, 10);
|
|
||||||
|
|
||||||
const names = ranked.map(([name]) => name);
|
|
||||||
|
|
||||||
// Round only the outer ends of the stacked bar; the seam where the input and
|
|
||||||
// output segments meet stays square ([topLeft, topRight, bottomRight, bottomLeft]).
|
|
||||||
const radius = 2;
|
|
||||||
const getBorderRadius = (index: number, count: number) => {
|
|
||||||
if (count <= 1) {
|
|
||||||
return [radius, radius, radius, radius];
|
|
||||||
}
|
|
||||||
if (index === 0) {
|
|
||||||
return [radius, 0, 0, radius];
|
|
||||||
}
|
|
||||||
if (index === count - 1) {
|
|
||||||
return [0, radius, radius, 0];
|
|
||||||
}
|
|
||||||
return [0, 0, 0, 0];
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
names,
|
names,
|
||||||
series: seriesDefs.map((def, index) => ({
|
series: [
|
||||||
name: def.name,
|
{
|
||||||
color: def.color,
|
name: seriesName,
|
||||||
data: ranked.map(([name, entry]) => ({
|
color,
|
||||||
name,
|
data: items.map((item) => ({
|
||||||
value: entry.values[index],
|
name: item.name,
|
||||||
itemStyle: {
|
value: item.value,
|
||||||
borderRadius: getBorderRadius(index, seriesDefs.length)
|
itemStyle: {
|
||||||
}
|
borderRadius: [2, 2, 2, 2]
|
||||||
}))
|
}
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data';
|
import useQueryTimeSeriesData from '@/pages/usage/services/use-query-timeseries-data';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
baseColorMap,
|
baseColorMap,
|
||||||
DashboardUsageCommonParams,
|
DashboardUsageCommonParams,
|
||||||
toUsageTokenBreakdownData
|
toUsageRankData
|
||||||
} from '../config';
|
} from '../config';
|
||||||
|
|
||||||
export default function useTopTokenUsageByUser(
|
export default function useTopTokenUsageByUser(
|
||||||
commonParams: DashboardUsageCommonParams
|
commonParams: DashboardUsageCommonParams
|
||||||
) {
|
) {
|
||||||
|
const intl = useIntl();
|
||||||
const query = useQueryTimeSeriesData({
|
const query = useQueryTimeSeriesData({
|
||||||
key: 'topTokenUsageByUserData'
|
key: 'topTokenUsageByUserData'
|
||||||
});
|
});
|
||||||
|
const tokenUsageText = intl.formatMessage({ id: 'dashboard.tokens' });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
query
|
query
|
||||||
@@ -28,19 +31,13 @@ export default function useTopTokenUsageByUser(
|
|||||||
|
|
||||||
const rankData = useMemo(
|
const rankData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
toUsageTokenBreakdownData(query.detailData, 'user', [
|
toUsageRankData(
|
||||||
{
|
query.detailData,
|
||||||
name: 'Prompt Tokens',
|
'user',
|
||||||
key: 'input_tokens',
|
tokenUsageText,
|
||||||
color: baseColorMap.base
|
baseColorMap.base
|
||||||
},
|
),
|
||||||
{
|
[query.detailData, tokenUsageText]
|
||||||
name: 'Completion Tokens',
|
|
||||||
key: 'output_tokens',
|
|
||||||
color: baseColorMap.baseR3
|
|
||||||
}
|
|
||||||
]),
|
|
||||||
[query.detailData]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ export interface ListItem extends FormData {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
creator_id?: number | null;
|
creator_id?: number | null;
|
||||||
clusterId: number;
|
|
||||||
status?: InstanceStatus | null;
|
status?: InstanceStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,25 +87,10 @@ const GPUServicePublicKeys: React.FC = () => {
|
|||||||
if (val === 'edit') {
|
if (val === 'edit') {
|
||||||
handleEdit(row);
|
handleEdit(row);
|
||||||
} else if (val === 'delete') {
|
} else if (val === 'delete') {
|
||||||
handleDelete(
|
handleDelete({ ...row, name: row.name as string });
|
||||||
{ ...row, name: row.name as string },
|
|
||||||
{
|
|
||||||
tips: intl.formatMessage({
|
|
||||||
id: 'gpuservice.publicKey.delete.tips'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDeleteByBatch = () => {
|
|
||||||
handleDeleteBatch({
|
|
||||||
tips: intl.formatMessage({
|
|
||||||
id: 'gpuservice.publicKey.delete.tips'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderEmpty = (type?: string) => {
|
const renderEmpty = (type?: string) => {
|
||||||
if (type !== 'Table') return;
|
if (type !== 'Table') return;
|
||||||
return (
|
return (
|
||||||
@@ -145,7 +130,7 @@ const GPUServicePublicKeys: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
buttonText={intl.formatMessage({ id: 'gpuservice.publicKey.add' })}
|
buttonText={intl.formatMessage({ id: 'gpuservice.publicKey.add' })}
|
||||||
handleSearch={handleSearch}
|
handleSearch={handleSearch}
|
||||||
handleDeleteByBatch={handleDeleteByBatch}
|
handleDeleteByBatch={handleDeleteBatch}
|
||||||
handleClickPrimary={handleAdd}
|
handleClickPrimary={handleAdd}
|
||||||
handleInputChange={handleNameChange}
|
handleInputChange={handleNameChange}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export const AUTH_API = '/auth';
|
|||||||
|
|
||||||
export const AUTH_CONFIG_API = '/auth/config';
|
export const AUTH_CONFIG_API = '/auth/config';
|
||||||
|
|
||||||
|
export const AUTH_OIDC_LOGIN_API = '/auth/oidc/login';
|
||||||
|
export const AUTH_SAML_LOGIN_API = '/auth/saml/login';
|
||||||
|
|
||||||
export const login = async (
|
export const login = async (
|
||||||
params: { username: string; password: string },
|
params: { username: string; password: string },
|
||||||
options?: any
|
options?: any
|
||||||
@@ -50,18 +53,10 @@ export const updatePassword = async (params: any) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExternalAuth = {
|
|
||||||
// Provider kind (``OIDC`` / ``SAML`` / ``CAS`` / …). Stays a free-form
|
|
||||||
// string so adding a new provider on the backend doesn't require a
|
|
||||||
// TypeScript change here.
|
|
||||||
type: string;
|
|
||||||
// Browser-facing login URL the SSO button should navigate to.
|
|
||||||
login_url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchAuthConfig = async () => {
|
export const fetchAuthConfig = async () => {
|
||||||
return request<{
|
return request<{
|
||||||
external_auth: ExternalAuth | null;
|
is_saml: boolean;
|
||||||
|
is_oidc: boolean;
|
||||||
first_time_setup: boolean;
|
first_time_setup: boolean;
|
||||||
get_initial_password_command: string;
|
get_initial_password_command: string;
|
||||||
}>(AUTH_CONFIG_API);
|
}>(AUTH_CONFIG_API);
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ interface LocalUserFormProps {
|
|||||||
form: FormInstance;
|
form: FormInstance;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
loginOption: {
|
loginOption: {
|
||||||
|
saml: boolean;
|
||||||
|
oidc: boolean;
|
||||||
first_time_setup: boolean;
|
first_time_setup: boolean;
|
||||||
get_initial_password_command: string;
|
get_initial_password_command: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -182,12 +182,18 @@ const LoginForm = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLoginWithThirdParty = () => {
|
const handleLoginWithThirdParty = () => {
|
||||||
SSOAuth.loginWithExternalAuth();
|
if (SSOAuth.options.oidc) {
|
||||||
|
SSOAuth.loginWithOIDC();
|
||||||
|
} else if (SSOAuth.options.saml) {
|
||||||
|
SSOAuth.loginWithSAML();
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasThirdPartyLogin = !!SSOAuth.options.external_auth;
|
const hasThirdPartyLogin = useMemo(() => {
|
||||||
|
return SSOAuth.options.oidc || SSOAuth.options.saml;
|
||||||
|
}, [SSOAuth.options]);
|
||||||
|
|
||||||
const isThirdPartyAuthHandling = useMemo(() => {
|
const isThirdPartyAuthHandling = useMemo(() => {
|
||||||
return loading && !authError;
|
return loading && !authError;
|
||||||
@@ -199,8 +205,18 @@ const LoginForm = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Buttons>
|
<Buttons>
|
||||||
{SSOAuth.options.external_auth && (
|
{SSOAuth.options.oidc && (
|
||||||
<ButtonWrapper onClick={SSOAuth.loginWithExternalAuth}>
|
<ButtonWrapper onClick={SSOAuth.loginWithOIDC}>
|
||||||
|
<ButtonText>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: 'common.external.login' },
|
||||||
|
{ type: 'SSO' }
|
||||||
|
)}
|
||||||
|
</ButtonText>
|
||||||
|
</ButtonWrapper>
|
||||||
|
)}
|
||||||
|
{SSOAuth.options.saml && (
|
||||||
|
<ButtonWrapper onClick={SSOAuth.loginWithSAML}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
{ id: 'common.external.login' },
|
{ id: 'common.external.login' },
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
// hooks/useSSOAuth.ts
|
// hooks/useSSOAuth.ts
|
||||||
import { history, useIntl } from '@umijs/max';
|
import { history, useIntl } from '@umijs/max';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ExternalAuth, fetchAuthConfig } from '../apis';
|
import {
|
||||||
|
AUTH_OIDC_LOGIN_API,
|
||||||
|
AUTH_SAML_LOGIN_API,
|
||||||
|
fetchAuthConfig
|
||||||
|
} from '../apis';
|
||||||
|
|
||||||
type LoginOption = {
|
type LoginOption = {
|
||||||
// Active external auth provider, or ``null`` when only local login is
|
saml: boolean;
|
||||||
// configured. Drives the SSO button: when set, render a button that
|
oidc: boolean;
|
||||||
// navigates to ``external_auth.login_url``.
|
|
||||||
external_auth: ExternalAuth | null;
|
|
||||||
first_time_setup: boolean;
|
first_time_setup: boolean;
|
||||||
get_initial_password_command: string;
|
get_initial_password_command: string;
|
||||||
};
|
};
|
||||||
@@ -24,7 +26,8 @@ export function useSSOAuth({
|
|||||||
onLoading?: (loading: boolean) => void;
|
onLoading?: (loading: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [loginOption, setLoginOption] = useState<LoginOption>({
|
const [loginOption, setLoginOption] = useState<LoginOption>({
|
||||||
external_auth: null,
|
saml: false,
|
||||||
|
oidc: false,
|
||||||
first_time_setup: false,
|
first_time_setup: false,
|
||||||
get_initial_password_command: ''
|
get_initial_password_command: ''
|
||||||
});
|
});
|
||||||
@@ -35,28 +38,29 @@ export function useSSOAuth({
|
|||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const sso = params.get('sso');
|
const sso = params.get('sso');
|
||||||
|
|
||||||
const loginWithExternalAuth = (auth: ExternalAuth | null) => {
|
const oidcLogin = () => {
|
||||||
if (auth) {
|
window.location.href = AUTH_OIDC_LOGIN_API;
|
||||||
window.location.href = auth.login_url;
|
};
|
||||||
}
|
|
||||||
|
const samlLogin = () => {
|
||||||
|
window.location.href = AUTH_SAML_LOGIN_API;
|
||||||
};
|
};
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
const { external_auth, ...rest } = await fetchAuthConfig();
|
const { is_oidc, is_saml, ...rest } = await fetchAuthConfig();
|
||||||
setLoginOption({
|
setLoginOption({
|
||||||
...rest,
|
...rest,
|
||||||
external_auth: external_auth ?? null
|
oidc: !!is_oidc,
|
||||||
|
saml: !!is_saml
|
||||||
});
|
});
|
||||||
if (sso) {
|
if (sso) {
|
||||||
onLoading?.(true);
|
onLoading?.(true);
|
||||||
if (external_auth) {
|
if (is_oidc) {
|
||||||
loginWithExternalAuth(external_auth);
|
oidcLogin();
|
||||||
|
} else if (is_saml) {
|
||||||
|
samlLogin();
|
||||||
} else {
|
} else {
|
||||||
// ``?sso`` deep-link landed on a server with no external auth
|
|
||||||
// configured. Surface the error AND release the loading
|
|
||||||
// state — otherwise the form is stuck on the spinner.
|
|
||||||
onLoading?.(false);
|
|
||||||
onError?.(
|
onError?.(
|
||||||
new Error(intl.formatMessage({ id: 'common.sso.noConfig' }))
|
new Error(intl.formatMessage({ id: 'common.sso.noConfig' }))
|
||||||
);
|
);
|
||||||
@@ -64,15 +68,12 @@ export function useSSOAuth({
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setLoginOption({
|
setLoginOption({
|
||||||
external_auth: null,
|
oidc: false,
|
||||||
|
saml: false,
|
||||||
first_time_setup: false,
|
first_time_setup: false,
|
||||||
get_initial_password_command: ''
|
get_initial_password_command: ''
|
||||||
});
|
});
|
||||||
onLoading?.(false);
|
onLoading?.(false);
|
||||||
// ``fetchAuthConfig`` failed (network, server 5xx, …). Without
|
|
||||||
// propagating, the login UI silently falls back to local-only —
|
|
||||||
// which can mask a real ``?sso`` redirect failure.
|
|
||||||
onError?.(error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ export function useSSOAuth({
|
|||||||
return {
|
return {
|
||||||
isSSOLogin: !!sso,
|
isSSOLogin: !!sso,
|
||||||
options: loginOption,
|
options: loginOption,
|
||||||
loginWithExternalAuth: () =>
|
loginWithOIDC: oidcLogin,
|
||||||
loginWithExternalAuth(loginOption.external_auth)
|
loginWithSAML: samlLogin
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,15 +110,12 @@ const ParamsSettings: React.FC<ParamsSettingsProps> = forwardRef(
|
|||||||
const newList = updateVoiceOptions(model!);
|
const newList = updateVoiceOptions(model!);
|
||||||
setModelMeta(model?.meta || {});
|
setModelMeta(model?.meta || {});
|
||||||
|
|
||||||
form.resetFields();
|
|
||||||
|
|
||||||
const values = {
|
const values = {
|
||||||
..._.pick(model?.meta || {}, MetaFields),
|
..._.pick(model?.meta || {}, MetaFields),
|
||||||
task_type: model?.meta?.task_type,
|
task_type: model?.meta?.task_type,
|
||||||
model: value,
|
model: value,
|
||||||
language: model?.meta?.languages?.[0] || '',
|
language: model?.meta?.languages?.[0] || '',
|
||||||
voice: newList[0]?.value,
|
voice: newList[0]?.value
|
||||||
x_vector_only_mode: model?.meta?.x_vector_only_mode || null
|
|
||||||
};
|
};
|
||||||
updatateParams(values);
|
updatateParams(values);
|
||||||
form.setFieldsValue(values);
|
form.setFieldsValue(values);
|
||||||
|
|||||||
@@ -1,150 +1,92 @@
|
|||||||
import useUserSettings from '@/hooks/use-user-settings';
|
import useUserSettings from '@/hooks/use-user-settings';
|
||||||
import langConfigMap from '@/locales/lang-config-map';
|
import langConfigMap from '@/locales/lang-config-map';
|
||||||
import { CheckCircleFilled } from '@ant-design/icons';
|
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
|
||||||
import { BaseSelect } from '@gpustack/core-ui';
|
import { BaseSelect } from '@gpustack/core-ui';
|
||||||
import { getAllLocales, setLocale, useIntl } from '@umijs/max';
|
import { getAllLocales, setLocale, useIntl } from '@umijs/max';
|
||||||
import { createStyles } from 'antd-style';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { SettingRow, SettingsGroup } from './settings-group';
|
import styled from 'styled-components';
|
||||||
import ThemePreview, { PreviewMode } from './theme-preview';
|
const Wrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
const useStyles = createStyles(({ token, css }) => ({
|
const SettingsItem = styled.div`
|
||||||
cards: css`
|
display: flex;
|
||||||
display: grid;
|
align-items: center;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
justify-content: space-between;
|
||||||
gap: 16px;
|
max-width: 300px;
|
||||||
`,
|
.label {
|
||||||
card: css`
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid ${token.colorBorderSecondary};
|
|
||||||
border-radius: ${token.borderRadiusLG + 2}px;
|
|
||||||
background: ${token.colorBgContainer};
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
border-color 0.2s,
|
|
||||||
box-shadow 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: ${token.colorPrimaryBorderHover};
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
cardActive: css`
|
|
||||||
border-color: ${token.colorPrimary};
|
|
||||||
box-shadow: 0 0 0 1px ${token.colorPrimary};
|
|
||||||
`,
|
|
||||||
meta: css`
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 8px 6px;
|
|
||||||
`,
|
|
||||||
label: css`
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-500);
|
||||||
color: ${token.colorText};
|
}
|
||||||
`,
|
`;
|
||||||
check: css`
|
|
||||||
font-size: 18px;
|
|
||||||
color: ${token.colorPrimary};
|
|
||||||
`,
|
|
||||||
radio: css`
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1.5px solid ${token.colorBorder};
|
|
||||||
`
|
|
||||||
}));
|
|
||||||
|
|
||||||
const Appearance: React.FC = () => {
|
const Appearance: React.FC = () => {
|
||||||
const { setTheme, userSettings } = useUserSettings();
|
const { setTheme, userSettings } = useUserSettings();
|
||||||
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { styles } = useStyles();
|
|
||||||
const allLocals = getAllLocales();
|
const allLocals = getAllLocales();
|
||||||
|
|
||||||
const themeOptions: { value: PreviewMode; label: string }[] = [
|
const ThemeOptions = [
|
||||||
{
|
{
|
||||||
value: 'light',
|
value: 'light',
|
||||||
label: intl.formatMessage({ id: 'common.appearance.lightmode' })
|
label: intl.formatMessage({ id: 'common.appearance.light' }),
|
||||||
|
icon: <SunOutlined />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'realDark',
|
value: 'realDark',
|
||||||
label: intl.formatMessage({ id: 'common.appearance.darkmode' })
|
label: intl.formatMessage({ id: 'common.appearance.dark' }),
|
||||||
|
icon: <MoonOutlined />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'auto',
|
value: 'auto',
|
||||||
label: intl.formatMessage({ id: 'common.appearance.system' })
|
label: intl.formatMessage({ id: 'common.appearance.system' }),
|
||||||
|
icon: <SunOutlined />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleOnChange = (value: 'light' | 'realDark' | 'auto') => {
|
||||||
|
setTheme(value);
|
||||||
|
};
|
||||||
|
|
||||||
const languageOptions = allLocals.map((locale) => ({
|
const languageOptions = allLocals.map((locale) => ({
|
||||||
value: locale,
|
value: locale,
|
||||||
label: _.get(langConfigMap, [locale, 'label'])
|
label: _.get(langConfigMap, [locale, 'label'])
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleSelectTheme = (value: PreviewMode) => {
|
|
||||||
setTheme(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsGroup>
|
<Wrapper>
|
||||||
<SettingRow
|
<SettingsItem>
|
||||||
title={intl.formatMessage({ id: 'common.appearance.theme' })}
|
<span className="label">
|
||||||
description={intl.formatMessage({ id: 'common.appearance.tips' })}
|
<span>{intl.formatMessage({ id: 'common.appearance.theme' })}</span>
|
||||||
>
|
</span>
|
||||||
<div className={styles.cards}>
|
<BaseSelect
|
||||||
{themeOptions.map((option) => {
|
defaultValue={'light'}
|
||||||
const active = userSettings.mode === option.value;
|
value={userSettings.mode}
|
||||||
return (
|
options={ThemeOptions}
|
||||||
<div
|
onChange={handleOnChange}
|
||||||
key={option.value}
|
style={{ width: 200 }}
|
||||||
role="radio"
|
></BaseSelect>
|
||||||
aria-checked={active}
|
</SettingsItem>
|
||||||
tabIndex={0}
|
<SettingsItem>
|
||||||
className={classNames(styles.card, {
|
<span className="label">
|
||||||
[styles.cardActive]: active
|
<span>{intl.formatMessage({ id: 'common.settings.language' })}</span>
|
||||||
})}
|
</span>
|
||||||
onClick={() => handleSelectTheme(option.value)}
|
<BaseSelect
|
||||||
onKeyDown={(e) => {
|
value={intl.locale}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
options={languageOptions}
|
||||||
e.preventDefault();
|
onChange={(value) => {
|
||||||
handleSelectTheme(option.value);
|
setLocale(value, false);
|
||||||
}
|
}}
|
||||||
}}
|
style={{ width: 200 }}
|
||||||
>
|
></BaseSelect>
|
||||||
<ThemePreview mode={option.value} />
|
</SettingsItem>
|
||||||
<div className={styles.meta}>
|
</Wrapper>
|
||||||
<span className={styles.label}>{option.label}</span>
|
|
||||||
{active ? (
|
|
||||||
<CheckCircleFilled className={styles.check} />
|
|
||||||
) : (
|
|
||||||
<span className={styles.radio} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title={intl.formatMessage({ id: 'common.settings.language' })}
|
|
||||||
description={intl.formatMessage({
|
|
||||||
id: 'common.settings.language.tips'
|
|
||||||
})}
|
|
||||||
extra={
|
|
||||||
<BaseSelect
|
|
||||||
value={intl.locale}
|
|
||||||
options={languageOptions}
|
|
||||||
onChange={(value: string) => {
|
|
||||||
setLocale(value, false);
|
|
||||||
}}
|
|
||||||
style={{ width: 200 }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsGroup>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import { PasswordReg } from '@/config';
|
|
||||||
import { INPUT_WIDTH } from '@/constants';
|
|
||||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
|
||||||
import { updatePassword } from '@/pages/login/apis';
|
|
||||||
import { Input as CInput, FormButtons } from '@gpustack/core-ui';
|
|
||||||
import { useIntl } from '@umijs/max';
|
|
||||||
import { Form, message } from 'antd';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface FormData {
|
|
||||||
new_password: string;
|
|
||||||
current_password: string;
|
|
||||||
confirm_password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModifyPasswordFormProps {
|
|
||||||
onCancel: () => void;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModifyPasswordForm: React.FC<ModifyPasswordFormProps> = ({
|
|
||||||
onCancel,
|
|
||||||
onSuccess
|
|
||||||
}) => {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const intl = useIntl();
|
|
||||||
const { guard, run, release } = useSubmitLock();
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
guard(() => form.submit());
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFinish = async (values: FormData) => {
|
|
||||||
await run(async () => {
|
|
||||||
await updatePassword({
|
|
||||||
new_password: values.new_password,
|
|
||||||
current_password: values.current_password
|
|
||||||
});
|
|
||||||
message.success(intl.formatMessage({ id: 'common.message.success' }));
|
|
||||||
onSuccess?.();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form
|
|
||||||
name="modifyPasswordForm"
|
|
||||||
form={form}
|
|
||||||
onFinish={onFinish}
|
|
||||||
onFinishFailed={release}
|
|
||||||
preserve={false}
|
|
||||||
>
|
|
||||||
<Form.Item<FormData>
|
|
||||||
name="current_password"
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: intl.formatMessage(
|
|
||||||
{ id: 'common.form.rule.input' },
|
|
||||||
{
|
|
||||||
name: intl.formatMessage({
|
|
||||||
id: 'users.form.currentpassword'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CInput.Password
|
|
||||||
autoComplete="current-password"
|
|
||||||
label={intl.formatMessage({ id: 'users.form.currentpassword' })}
|
|
||||||
required
|
|
||||||
style={{ width: INPUT_WIDTH.default }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item<FormData>
|
|
||||||
name="new_password"
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
pattern: PasswordReg,
|
|
||||||
message: intl.formatMessage({
|
|
||||||
id: 'users.form.rule.password'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CInput.Password
|
|
||||||
autoComplete="new-password"
|
|
||||||
label={intl.formatMessage({ id: 'users.form.newpassword' })}
|
|
||||||
required
|
|
||||||
style={{ width: INPUT_WIDTH.default }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="confirm_password"
|
|
||||||
dependencies={['new_password']}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: intl.formatMessage({
|
|
||||||
id: 'users.password.confirm.empty'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
({ getFieldValue }) => ({
|
|
||||||
validator(_, value) {
|
|
||||||
if (!value || getFieldValue('new_password') === value) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return Promise.reject(
|
|
||||||
new Error(
|
|
||||||
intl.formatMessage({ id: 'users.password.confirm.error' })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CInput.Password
|
|
||||||
required
|
|
||||||
autoComplete="new-password"
|
|
||||||
style={{ width: INPUT_WIDTH.default }}
|
|
||||||
label={intl.formatMessage({ id: 'users.password.confirm' })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<FormButtons htmlType="submit" onCancel={onCancel} showCancel />
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ModifyPasswordForm.displayName = 'ModifyPasswordForm';
|
|
||||||
|
|
||||||
export default ModifyPasswordForm;
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { INPUT_WIDTH } from '@/constants';
|
||||||
|
import { Textarea } from '@gpustack/core-ui';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button, Form } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface PublicKeyFormData {
|
||||||
|
public_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PublicKey: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [form] = Form.useForm<PublicKeyFormData>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form style={{ width: '524px' }} name="publicKeyForm" form={form}>
|
||||||
|
<Form.Item<PublicKeyFormData> name="public_key">
|
||||||
|
<Textarea
|
||||||
|
label="SSH 公钥"
|
||||||
|
placeholder="将您的 SSH 公钥粘贴到此处"
|
||||||
|
trim={false}
|
||||||
|
alwaysFocus
|
||||||
|
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||||
|
style={{ width: INPUT_WIDTH.default }}
|
||||||
|
></Textarea>
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" style={{ width: 120, marginTop: 100 }}>
|
||||||
|
{intl.formatMessage({ id: 'common.button.save' })}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PublicKey.displayName = 'PublicKey';
|
||||||
|
|
||||||
|
export default PublicKey;
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { useIntl } from '@umijs/max';
|
|
||||||
import { Button } from 'antd';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import ModifyPasswordForm from './modify-password-form';
|
|
||||||
import { SettingRow, SettingsGroup } from './settings-group';
|
|
||||||
|
|
||||||
const Security: React.FC = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsGroup>
|
|
||||||
<SettingRow
|
|
||||||
title={intl.formatMessage({ id: 'users.form.updatepassword' })}
|
|
||||||
description={intl.formatMessage({
|
|
||||||
id: 'users.password.modify.tips'
|
|
||||||
})}
|
|
||||||
extra={
|
|
||||||
!open && (
|
|
||||||
<Button onClick={() => setOpen(true)}>
|
|
||||||
{intl.formatMessage({ id: 'users.form.updatepassword' })}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{open && (
|
|
||||||
<ModifyPasswordForm
|
|
||||||
onCancel={() => setOpen(false)}
|
|
||||||
onSuccess={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsGroup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Security.displayName = 'Security';
|
|
||||||
|
|
||||||
export default Security;
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { createStyles } from 'antd-style';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
const useStyles = createStyles(({ token, css }) => ({
|
|
||||||
group: css`
|
|
||||||
/* Match the page panel surface (page-box.less): 8px radius + the
|
|
||||||
lighter container border, rather than the heavier component token. */
|
|
||||||
border: 1px solid ${token.colorBorder};
|
|
||||||
border-radius: 8px;
|
|
||||||
background: ${token.colorBgContainer};
|
|
||||||
overflow: hidden;
|
|
||||||
`,
|
|
||||||
row: css`
|
|
||||||
padding: 16px 20px;
|
|
||||||
|
|
||||||
& + & {
|
|
||||||
border-top: 1px solid ${token.colorBorderSecondary};
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
head: css`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
`,
|
|
||||||
info: css`
|
|
||||||
min-width: 0;
|
|
||||||
`,
|
|
||||||
title: css`
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
color: ${token.colorText};
|
|
||||||
line-height: 22px;
|
|
||||||
`,
|
|
||||||
description: css`
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: ${token.colorTextTertiary};
|
|
||||||
line-height: 20px;
|
|
||||||
`,
|
|
||||||
extra: css`
|
|
||||||
flex-shrink: 0;
|
|
||||||
`,
|
|
||||||
body: css`
|
|
||||||
margin-top: 16px;
|
|
||||||
`
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const SettingsGroup: React.FC<{ children: React.ReactNode }> = ({
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
const { styles } = useStyles();
|
|
||||||
return <div className={styles.group}>{children}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SettingRowProps {
|
|
||||||
title: React.ReactNode;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
extra?: React.ReactNode;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingRow: React.FC<SettingRowProps> = ({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
extra,
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
const { styles } = useStyles();
|
|
||||||
return (
|
|
||||||
<div className={styles.row}>
|
|
||||||
<div className={styles.head}>
|
|
||||||
<div className={styles.info}>
|
|
||||||
<div className={styles.title}>{title}</div>
|
|
||||||
{description && (
|
|
||||||
<div className={styles.description}>{description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{extra && <div className={styles.extra}>{extra}</div>}
|
|
||||||
</div>
|
|
||||||
{children && <div className={styles.body}>{children}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { createStyles } from 'antd-style';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
const useStyles = createStyles(({ token, css }) => ({
|
|
||||||
section: css`
|
|
||||||
& + & {
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
header: css`
|
|
||||||
margin-bottom: 16px;
|
|
||||||
`,
|
|
||||||
title: css`
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
color: ${token.colorTextHeading};
|
|
||||||
line-height: 24px;
|
|
||||||
`,
|
|
||||||
description: css`
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: ${token.colorTextTertiary};
|
|
||||||
line-height: 20px;
|
|
||||||
`
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface SettingsSectionProps {
|
|
||||||
title: React.ReactNode;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SettingsSection: React.FC<SettingsSectionProps> = ({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
const { styles } = useStyles();
|
|
||||||
return (
|
|
||||||
<section className={styles.section}>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<h3 className={styles.title}>{title}</h3>
|
|
||||||
{description && <p className={styles.description}>{description}</p>}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
SettingsSection.displayName = 'SettingsSection';
|
|
||||||
|
|
||||||
export default SettingsSection;
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { createStyles } from 'antd-style';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export type PreviewMode = 'light' | 'realDark' | 'auto';
|
|
||||||
|
|
||||||
const PALETTE = {
|
|
||||||
light: {
|
|
||||||
surface: '#ffffff',
|
|
||||||
bar: '#e7ebf2',
|
|
||||||
block: '#f1f4f9',
|
|
||||||
accent: '#cdd7e8'
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
surface: '#0f1729',
|
|
||||||
bar: '#1d2740',
|
|
||||||
block: '#27324d',
|
|
||||||
accent: '#39445f'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const useStyles = createStyles(({ css }) => ({
|
|
||||||
preview: css`
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 10;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
`,
|
|
||||||
half: css`
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 10px;
|
|
||||||
gap: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
`,
|
|
||||||
topbar: css`
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
`,
|
|
||||||
body: css`
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
`,
|
|
||||||
sidebar: css`
|
|
||||||
width: 22%;
|
|
||||||
border-radius: 4px;
|
|
||||||
`,
|
|
||||||
main: css`
|
|
||||||
flex: 1;
|
|
||||||
border-radius: 4px;
|
|
||||||
`,
|
|
||||||
footer: css`
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
`,
|
|
||||||
systemIcon: css`
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
font-size: 24px;
|
|
||||||
color: rgba(255, 255, 255, 0.55);
|
|
||||||
mix-blend-mode: difference;
|
|
||||||
z-index: 2;
|
|
||||||
`,
|
|
||||||
systemWrap: css`
|
|
||||||
position: relative;
|
|
||||||
`
|
|
||||||
}));
|
|
||||||
|
|
||||||
const Mockup: React.FC<{ tone: 'light' | 'dark'; flex?: number }> = ({
|
|
||||||
tone,
|
|
||||||
flex = 1
|
|
||||||
}) => {
|
|
||||||
const { styles } = useStyles();
|
|
||||||
const c = PALETTE[tone];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.half}
|
|
||||||
style={{ background: c.surface, flex, minWidth: 0 }}
|
|
||||||
>
|
|
||||||
<div className={styles.topbar} style={{ background: c.accent }} />
|
|
||||||
<div className={styles.body}>
|
|
||||||
<div className={styles.sidebar} style={{ background: c.block }} />
|
|
||||||
<div className={styles.main} style={{ background: c.block }} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.footer} style={{ background: c.bar }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemePreview: React.FC<{ mode: PreviewMode }> = ({ mode }) => {
|
|
||||||
const { styles } = useStyles();
|
|
||||||
|
|
||||||
if (mode === 'auto') {
|
|
||||||
return (
|
|
||||||
<div className={`${styles.preview} ${styles.systemWrap}`}>
|
|
||||||
<Mockup tone="light" />
|
|
||||||
<Mockup tone="dark" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.preview}>
|
|
||||||
<Mockup tone={mode === 'realDark' ? 'dark' : 'light'} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ThemePreview.displayName = 'ThemePreview';
|
|
||||||
|
|
||||||
export default ThemePreview;
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import useTabActive from '@/hooks/use-tab-active';
|
|
||||||
import { useIntl, useModel } from '@umijs/max';
|
|
||||||
import { Tabs, TabsProps } from 'antd';
|
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import PageBox from '../_components/page-box';
|
|
||||||
import Appearance from './components/appearance';
|
|
||||||
import ModifyPasswordn from './components/modify-password';
|
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
|
||||||
.ant-page-header-heading {
|
|
||||||
padding-inline: 8px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Profile: React.FC = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const { initialState, setInitialState } = useModel('@@initialState') || {};
|
|
||||||
const { setTabActive, getTabActive, tabsMap } = useTabActive();
|
|
||||||
const [activeKey, setActiveKey] = useState(
|
|
||||||
initialState?.currentUser?.source === 'Local'
|
|
||||||
? 'modify-password'
|
|
||||||
: 'appearance'
|
|
||||||
);
|
|
||||||
|
|
||||||
const items: TabsProps['items'] = useMemo(() => {
|
|
||||||
if (initialState?.currentUser?.source !== 'Local') {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: 'appearance',
|
|
||||||
label: intl.formatMessage({ id: 'common.appearance' }),
|
|
||||||
children: <Appearance />
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: 'modify-password',
|
|
||||||
label: intl.formatMessage({ id: 'users.form.updatepassword' }),
|
|
||||||
children: <ModifyPasswordn />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'appearance',
|
|
||||||
label: intl.formatMessage({ id: 'common.appearance' }),
|
|
||||||
children: <Appearance />
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}, [intl, initialState?.currentUser?.source]);
|
|
||||||
|
|
||||||
const handleChangeTab = useCallback((key: string) => {
|
|
||||||
setActiveKey(key);
|
|
||||||
setTabActive(tabsMap.userSettings, key);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageBox>
|
|
||||||
<Tabs
|
|
||||||
activeKey={activeKey}
|
|
||||||
onChange={handleChangeTab}
|
|
||||||
items={items}
|
|
||||||
type="card"
|
|
||||||
/>
|
|
||||||
</PageBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Profile.displayName = 'Profile';
|
|
||||||
|
|
||||||
export default Profile;
|
|
||||||
+52
-36
@@ -1,49 +1,65 @@
|
|||||||
|
import useTabActive from '@/hooks/use-tab-active';
|
||||||
import { useIntl, useModel } from '@umijs/max';
|
import { useIntl, useModel } from '@umijs/max';
|
||||||
import { createStyles } from 'antd-style';
|
import { Tabs, TabsProps } from 'antd';
|
||||||
import React from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
import PageBox from '../_components/page-box';
|
import PageBox from '../_components/page-box';
|
||||||
import Appearance from './components/appearance';
|
import Appearance from './components/appearance';
|
||||||
import Security from './components/security';
|
import ModifyPasswordn from './components/modify-password';
|
||||||
import SettingsSection from './components/settings-section';
|
|
||||||
|
|
||||||
const useStyles = createStyles(({ css }) => ({
|
const Wrapper = styled.div`
|
||||||
wrapper: css`
|
.ant-page-header-heading {
|
||||||
width: 100%;
|
padding-inline: 8px;
|
||||||
max-width: 720px;
|
}
|
||||||
margin: 0 auto;
|
`;
|
||||||
padding: 8px 0 40px;
|
|
||||||
`
|
|
||||||
}));
|
|
||||||
|
|
||||||
const Profile: React.FC = () => {
|
const Profile: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { styles } = useStyles();
|
const { initialState, setInitialState } = useModel('@@initialState') || {};
|
||||||
const { initialState } = useModel('@@initialState') || {};
|
const { setTabActive, getTabActive, tabsMap } = useTabActive();
|
||||||
const isLocalUser = initialState?.currentUser?.source === 'Local';
|
const [activeKey, setActiveKey] = useState(
|
||||||
|
initialState?.currentUser?.source === 'Local'
|
||||||
|
? 'modify-password'
|
||||||
|
: 'appearance'
|
||||||
|
);
|
||||||
|
|
||||||
|
const items: TabsProps['items'] = useMemo(() => {
|
||||||
|
if (initialState?.currentUser?.source !== 'Local') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'appearance',
|
||||||
|
label: intl.formatMessage({ id: 'common.appearance' }),
|
||||||
|
children: <Appearance />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'modify-password',
|
||||||
|
label: intl.formatMessage({ id: 'users.form.updatepassword' }),
|
||||||
|
children: <ModifyPasswordn />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'appearance',
|
||||||
|
label: intl.formatMessage({ id: 'common.appearance' }),
|
||||||
|
children: <Appearance />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, [intl, initialState?.currentUser?.source]);
|
||||||
|
|
||||||
|
const handleChangeTab = useCallback((key: string) => {
|
||||||
|
setActiveKey(key);
|
||||||
|
setTabActive(tabsMap.userSettings, key);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageBox>
|
<PageBox>
|
||||||
<div className={styles.wrapper}>
|
<Tabs
|
||||||
<SettingsSection
|
activeKey={activeKey}
|
||||||
title={intl.formatMessage({ id: 'common.appearance' })}
|
onChange={handleChangeTab}
|
||||||
description={intl.formatMessage({
|
items={items}
|
||||||
id: 'common.appearance.description'
|
type="card"
|
||||||
})}
|
/>
|
||||||
>
|
|
||||||
<Appearance />
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{isLocalUser && (
|
|
||||||
<SettingsSection
|
|
||||||
title={intl.formatMessage({ id: 'common.security' })}
|
|
||||||
description={intl.formatMessage({
|
|
||||||
id: 'common.security.description'
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Security />
|
|
||||||
</SettingsSection>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PageBox>
|
</PageBox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export async function downloadWorkerPrivateKey({
|
|||||||
export async function queryWorkersList<T extends Record<string, any>>(
|
export async function queryWorkersList<T extends Record<string, any>>(
|
||||||
params: Global.SearchParams & T,
|
params: Global.SearchParams & T,
|
||||||
options?: {
|
options?: {
|
||||||
token?: any;
|
token: any;
|
||||||
skipErrorHandler?: boolean;
|
skipErrorHandler?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
* whole-machine SKU model meters runtime, not decomposed components.
|
* whole-machine SKU model meters runtime, not decomposed components.
|
||||||
*/
|
*/
|
||||||
import { request } from '@umijs/max';
|
import { request } from '@umijs/max';
|
||||||
import { instanceTypeSeriesLabel } from '../utils/format-instance-type';
|
|
||||||
|
|
||||||
export interface ResourceUsageFilters {
|
export interface ResourceUsageFilters {
|
||||||
creator_ids?: number[];
|
creator_ids?: number[];
|
||||||
@@ -74,10 +73,6 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
|
|||||||
unit_cpu_milli?: number;
|
unit_cpu_milli?: number;
|
||||||
unit_memory_mib?: number;
|
unit_memory_mib?: number;
|
||||||
vram_mib?: number;
|
vram_mib?: number;
|
||||||
// Instance totals (requested cpu/ram) — the real size, so CPU instance types
|
|
||||||
// show "CPU Only · 2 vCPU · 4 GB" instead of just the per-unit spec.
|
|
||||||
cpu_milli?: number;
|
|
||||||
memory_mib?: number;
|
|
||||||
// Per-instance rows also carry the card count + ephemeral disk so the
|
// Per-instance rows also carry the card count + ephemeral disk so the
|
||||||
// Instances table can render "<product> x <count>" + the spec popover.
|
// Instances table can render "<product> x <count>" + the spec popover.
|
||||||
gpu_count?: number;
|
gpu_count?: number;
|
||||||
@@ -207,8 +202,6 @@ interface ServerBreakdownItem {
|
|||||||
unit_cpu_milli?: number | null;
|
unit_cpu_milli?: number | null;
|
||||||
unit_memory_mib?: number | null;
|
unit_memory_mib?: number | null;
|
||||||
vram_mib?: number | null;
|
vram_mib?: number | null;
|
||||||
cpu_milli?: number | null;
|
|
||||||
memory_mib?: number | null;
|
|
||||||
gpu_count?: number | null;
|
gpu_count?: number | null;
|
||||||
ephemeral_mib?: number | null;
|
ephemeral_mib?: number | null;
|
||||||
local_storage_mib?: number | null;
|
local_storage_mib?: number | null;
|
||||||
@@ -320,8 +313,6 @@ function flattenItem(
|
|||||||
if (dims.unit_memory_mib != null)
|
if (dims.unit_memory_mib != null)
|
||||||
flat.unit_memory_mib = dims.unit_memory_mib;
|
flat.unit_memory_mib = dims.unit_memory_mib;
|
||||||
if (dims.vram_mib != null) flat.vram_mib = dims.vram_mib;
|
if (dims.vram_mib != null) flat.vram_mib = dims.vram_mib;
|
||||||
if (dims.cpu_milli != null) flat.cpu_milli = dims.cpu_milli;
|
|
||||||
if (dims.memory_mib != null) flat.memory_mib = dims.memory_mib;
|
|
||||||
if (dims.gpu_count != null) flat.gpu_count = dims.gpu_count;
|
if (dims.gpu_count != null) flat.gpu_count = dims.gpu_count;
|
||||||
if (dims.ephemeral_mib != null) flat.ephemeral_mib = dims.ephemeral_mib;
|
if (dims.ephemeral_mib != null) flat.ephemeral_mib = dims.ephemeral_mib;
|
||||||
if (dims.local_storage_mib != null)
|
if (dims.local_storage_mib != null)
|
||||||
@@ -330,15 +321,6 @@ function flattenItem(
|
|||||||
if (dims.storage_type) flat.storage_type = dims.storage_type;
|
if (dims.storage_type) flat.storage_type = dims.storage_type;
|
||||||
if (dims.capacity_mib != null) flat.capacity_mib = dims.capacity_mib;
|
if (dims.capacity_mib != null) flat.capacity_mib = dims.capacity_mib;
|
||||||
}
|
}
|
||||||
// Instance-type grouped trend: the series label (``group``) defaults to the
|
|
||||||
// raw flavor slug. Instance Types are grouped by actual shape, so label each
|
|
||||||
// series by that shape — "<product> x <cards>" / "CPU Only · 3 vCPU · 6 GB" —
|
|
||||||
// matching the table and keeping every shape a distinct series (#5700).
|
|
||||||
// ``groupBy`` is the unmapped frontend dimension; the instance-type axis is
|
|
||||||
// ``gpu_type`` (→ backend ``instance_type`` via GROUP_BY_MAP).
|
|
||||||
if (groupBy === 'gpu_type') {
|
|
||||||
flat.group = instanceTypeSeriesLabel(flat);
|
|
||||||
}
|
|
||||||
return flat;
|
return flat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,12 +163,11 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
|||||||
setPageParams({ page, perPage });
|
setPageParams({ page, perPage });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export the full filtered set, not just the visible page. ``page: -1`` is
|
// Export the full filtered set, not just the visible page.
|
||||||
// the backend's no-pagination sentinel (perPage is then ignored).
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const res = await queryFn(buildRequest(-1, INITIAL_PAGE.perPage));
|
const res = await queryFn(buildRequest(1, 10000));
|
||||||
exportBreakdownRows(
|
exportBreakdownRows(
|
||||||
res.items ?? [],
|
res.items ?? [],
|
||||||
toExportColumns(columns),
|
toExportColumns(columns),
|
||||||
|
|||||||
@@ -174,7 +174,6 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
|
|||||||
allowClear
|
allowClear
|
||||||
showSearch
|
showSearch
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
maxTagCount={'responsive'}
|
|
||||||
options={userOptions}
|
options={userOptions}
|
||||||
placeholder={intl.formatMessage({ id: 'usage.filter.user' })}
|
placeholder={intl.formatMessage({ id: 'usage.filter.user' })}
|
||||||
styles={{
|
styles={{
|
||||||
@@ -190,7 +189,6 @@ const ResourceFilterBar: React.FC<ResourceFilterBarProps> = (props) => {
|
|||||||
allowClear
|
allowClear
|
||||||
showSearch
|
showSearch
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
maxTagCount={'responsive'}
|
|
||||||
options={resourceFilter.options}
|
options={resourceFilter.options}
|
||||||
placeholder={resourceFilter.placeholder}
|
placeholder={resourceFilter.placeholder}
|
||||||
styles={{
|
styles={{
|
||||||
|
|||||||
@@ -96,12 +96,3 @@ export interface UsageMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
|
export type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
|
||||||
|
|
||||||
// The full breakdown filter set (route / user / api_key). Every breakdown
|
|
||||||
// table sends all active dimensions — matching the trend chart — so e.g. a
|
|
||||||
// user filter narrows the Models table too, not only the Users table.
|
|
||||||
export type BreakdownFilters = {
|
|
||||||
routes?: FilterOptionType[];
|
|
||||||
users?: FilterOptionType[];
|
|
||||||
api_keys?: FilterOptionType[];
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
import { exportJsonToExcel } from '@gpustack/core-ui/excel';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import _ from 'lodash';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { GroupOption } from '../config';
|
import { GroupOption } from '../config';
|
||||||
import { BreakdownItem, UsageFilterItem } from '../config/types';
|
import { BreakdownItem, UsageFilterItem } from '../config/types';
|
||||||
import useQueryTimeSeriesData from '../services/use-query-timeseries-data';
|
import useQueryTimeSeriesData from '../services/use-query-timeseries-data';
|
||||||
@@ -182,17 +181,10 @@ export const useUsageFilters = ({
|
|||||||
return filters;
|
return filters;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep a stable reference while the content is unchanged. ``buildFilters``
|
const filters = useMemo(
|
||||||
// returns a fresh object every render — and again when the meta options
|
() => buildFilters(commonFilters),
|
||||||
// resolve after mount — which would otherwise retrigger every breakdown
|
[commonFilters, routeOptions, userOptions, apiKeyOptions]
|
||||||
// table's fetch effect a second time on first load. Only a real selection
|
);
|
||||||
// change (or options resolving a previously-selected id) should swap it.
|
|
||||||
const filtersRef = useRef<ReturnType<typeof buildFilters>>({});
|
|
||||||
const nextFilters = buildFilters(commonFilters);
|
|
||||||
if (!_.isEqual(nextFilters, filtersRef.current)) {
|
|
||||||
filtersRef.current = nextFilters;
|
|
||||||
}
|
|
||||||
const filters = filtersRef.current;
|
|
||||||
|
|
||||||
const fetchData = (
|
const fetchData = (
|
||||||
currentSelectedFilters = commonFilters,
|
currentSelectedFilters = commonFilters,
|
||||||
@@ -216,11 +208,6 @@ export const useUsageFilters = ({
|
|||||||
fetchTimeSeriesData({
|
fetchTimeSeriesData({
|
||||||
...currentChartFilters,
|
...currentChartFilters,
|
||||||
group_by: groupByArray,
|
group_by: groupByArray,
|
||||||
// The trend chart needs the complete date series. ``page: -1`` is the
|
|
||||||
// backend's no-pagination sentinel — without it the default page (20
|
|
||||||
// buckets, sorted by total tokens) drops low-traffic dates, leaving
|
|
||||||
// gaps in the chart for ranges spanning more than a handful of buckets.
|
|
||||||
page: -1,
|
|
||||||
// Without ``scope`` the backend defaults to ``all``, while the
|
// Without ``scope`` the backend defaults to ``all``, while the
|
||||||
// breakdown tables pass ``scope`` explicitly. The mismatch makes
|
// breakdown tables pass ``scope`` explicitly. The mismatch makes
|
||||||
// the chart and the tables run different filters on the same
|
// the chart and the tables run different filters on the same
|
||||||
|
|||||||
@@ -148,8 +148,7 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
// whole range. The default order is metric-desc, so partial (current/
|
// whole range. The default order is metric-desc, so partial (current/
|
||||||
// recent) buckets have smaller values and would be pushed onto later
|
// recent) buckets have smaller values and would be pushed onto later
|
||||||
// pages — dropping the newest hours from the chart under a small page.
|
// pages — dropping the newest hours from the chart under a small page.
|
||||||
// ``page: -1`` is the backend's no-pagination sentinel.
|
perPage: 10000
|
||||||
page: -1
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -297,8 +296,7 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
...baseRequest(),
|
...baseRequest(),
|
||||||
group_by: [g.key],
|
group_by: [g.key],
|
||||||
// A breakdown export is the full filtered set, not a page.
|
// A breakdown export is the full filtered set, not a page.
|
||||||
// ``page: -1`` is the backend's no-pagination sentinel.
|
perPage: 10000
|
||||||
page: -1
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ResourceBreakdownItem } from '../../apis/resource';
|
import { ResourceBreakdownItem } from '../../apis/resource';
|
||||||
import { instanceTypeSeriesLabel } from '../../utils/format-instance-type';
|
import { instanceTypeLabel } from '../../utils/format-instance-type';
|
||||||
import { parseRollup } from '../../utils/time-buckets';
|
import { parseRollup } from '../../utils/time-buckets';
|
||||||
|
|
||||||
type GroupKey = 'gpu_type' | 'instance' | 'user';
|
type GroupKey = 'gpu_type' | 'instance' | 'user';
|
||||||
@@ -44,29 +44,25 @@ const useInstancesColumns = (groupKey: GroupKey) => {
|
|||||||
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
|
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
|
||||||
dataIndex: 'gpu_type',
|
dataIndex: 'gpu_type',
|
||||||
key: 'gpu_type',
|
key: 'gpu_type',
|
||||||
render: (_v: string, row: ResourceBreakdownItem) => {
|
render: (_v: string, row: ResourceBreakdownItem) =>
|
||||||
const isCpu = !row.gpu_count && !row.vram_mib;
|
renderInstanceType(
|
||||||
return renderInstanceType(
|
|
||||||
buildInstanceTypeRecordFromMiB({
|
buildInstanceTypeRecordFromMiB({
|
||||||
name: row.instance_name,
|
name: row.instance_name,
|
||||||
product: row.product || row.gpu_type,
|
product: row.product || row.gpu_type,
|
||||||
gpuCount: row.gpu_count,
|
gpuCount: row.gpu_count,
|
||||||
// CPU instance types show their real total size (cpu/mem totals);
|
unitCpuMilli: row.unit_cpu_milli,
|
||||||
// GPU keeps per-card specs since the renderer multiplies by the
|
unitMemoryMib: row.unit_memory_mib,
|
||||||
// card count.
|
|
||||||
unitCpuMilli: isCpu ? row.cpu_milli : row.unit_cpu_milli,
|
|
||||||
unitMemoryMib: isCpu ? row.memory_mib : row.unit_memory_mib,
|
|
||||||
vramMib: row.vram_mib
|
vramMib: row.vram_mib
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
intl,
|
intl,
|
||||||
categories: ['cpu', 'ram'],
|
categories: ['cpu', 'ram'],
|
||||||
// Each row is one shape: GPU "<product> x <cards>", CPU
|
title:
|
||||||
// "CPU Only · <spec>".
|
!!row.gpu_count || !!row.vram_mib
|
||||||
title: instanceTypeSeriesLabel(row)
|
? instanceTypeLabel(row)
|
||||||
|
: 'CPU Only'
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
}
|
|
||||||
};
|
};
|
||||||
// Instances breakdown: render through the canonical GPU Instances list
|
// Instances breakdown: render through the canonical GPU Instances list
|
||||||
// renderer so the label + spec popover are identical. The breakdown row
|
// renderer so the label + spec popover are identical. The breakdown row
|
||||||
@@ -75,29 +71,21 @@ const useInstancesColumns = (groupKey: GroupKey) => {
|
|||||||
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
|
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
|
||||||
dataIndex: 'gpu_type',
|
dataIndex: 'gpu_type',
|
||||||
key: 'gpu_type',
|
key: 'gpu_type',
|
||||||
render: (_v: string, row: ResourceBreakdownItem) => {
|
render: (_v: string, row: ResourceBreakdownItem) =>
|
||||||
const isCpu = !row.gpu_count && !row.vram_mib;
|
renderInstanceType(
|
||||||
return renderInstanceType(
|
|
||||||
buildInstanceTypeRecordFromMiB({
|
buildInstanceTypeRecordFromMiB({
|
||||||
name: row.instance_name,
|
name: row.instance_name,
|
||||||
product: row.product || row.gpu_type,
|
product: row.product || row.gpu_type,
|
||||||
gpuCount: row.gpu_count,
|
gpuCount: row.gpu_count,
|
||||||
// A per-instance row is one concrete instance, so CPU shows its
|
unitCpuMilli: row.unit_cpu_milli,
|
||||||
// real requested size (cpu/mem totals), not the per-unit flavor
|
unitMemoryMib: row.unit_memory_mib,
|
||||||
// spec — e.g. a 3c6g instance of a 1c2g flavor reads "3 vCPU · 6 GB".
|
|
||||||
unitCpuMilli: isCpu ? row.cpu_milli : row.unit_cpu_milli,
|
|
||||||
unitMemoryMib: isCpu ? row.memory_mib : row.unit_memory_mib,
|
|
||||||
vramMib: row.vram_mib,
|
vramMib: row.vram_mib,
|
||||||
localStorageMib: row.local_storage_mib,
|
localStorageMib: row.local_storage_mib,
|
||||||
ephemeralMib: row.ephemeral_mib,
|
ephemeralMib: row.ephemeral_mib,
|
||||||
persistentMib: row.persistent_mib
|
persistentMib: row.persistent_mib
|
||||||
}),
|
}),
|
||||||
// Label by shape directly (consistent with the Instance Types
|
{ intl }
|
||||||
// column); avoids renderInstanceType's "CPU Only" fallback when a
|
)
|
||||||
// GPU row has vram but a missing/zero gpu_count.
|
|
||||||
{ intl, title: instanceTypeSeriesLabel(row) }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
// Last Active = the last active day. The backend sends a rollup-tz instant
|
// Last Active = the last active day. The backend sends a rollup-tz instant
|
||||||
// with its offset; parseRollup keeps that wall clock (no browser-tz convert),
|
// with its offset; parseRollup keeps that wall clock (no browser-tz convert),
|
||||||
|
|||||||
@@ -140,8 +140,7 @@ const StorageTab: React.FC = () => {
|
|||||||
// whole range. The default order is metric-desc, so partial (current/
|
// whole range. The default order is metric-desc, so partial (current/
|
||||||
// recent) buckets have smaller values and would be pushed onto later
|
// recent) buckets have smaller values and would be pushed onto later
|
||||||
// pages — dropping the newest hours from the chart under a small page.
|
// pages — dropping the newest hours from the chart under a small page.
|
||||||
// ``page: -1`` is the backend's no-pagination sentinel.
|
perPage: 10000
|
||||||
page: -1
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -284,8 +283,7 @@ const StorageTab: React.FC = () => {
|
|||||||
...baseRequest(),
|
...baseRequest(),
|
||||||
group_by: [g.key],
|
group_by: [g.key],
|
||||||
// A breakdown export is the full filtered set, not a page.
|
// A breakdown export is the full filtered set, not a page.
|
||||||
// ``page: -1`` is the backend's no-pagination sentinel.
|
perPage: 10000
|
||||||
page: -1
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* each domain's natural unit (tokens / GPU-Hours / GB-Days); a true
|
* each domain's natural unit (tokens / GPU-Hours / GB-Days); a true
|
||||||
* cross-resource split needs a common unit.
|
* cross-resource split needs a common unit.
|
||||||
*/
|
*/
|
||||||
import { useCoolAccents } from '@/hooks/use-cool-colors';
|
import useCoolColors from '@/hooks/use-cool-colors';
|
||||||
import BarChart from '@/pages/_components/bar-chart';
|
import BarChart from '@/pages/_components/bar-chart';
|
||||||
import PieChart from '@/pages/_components/pie-chart';
|
import PieChart from '@/pages/_components/pie-chart';
|
||||||
import { formatLargeNumber } from '@/utils';
|
import { formatLargeNumber } from '@/utils';
|
||||||
@@ -31,9 +31,7 @@ import { Col, Row } from 'antd';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import ResourceFilterBar from '../components/resource-filter-bar';
|
import ResourceFilterBar from '../components/resource-filter-bar';
|
||||||
import { FilterOptionType } from '../config/types';
|
import useResourceMeta from '../hooks/use-resource-meta';
|
||||||
import useResourceMeta, { SelectOption } from '../hooks/use-resource-meta';
|
|
||||||
import useQueryUsageMetaData from '../services/use-query-meta-data';
|
|
||||||
import {
|
import {
|
||||||
bucketKey,
|
bucketKey,
|
||||||
generateBucketRange,
|
generateBucketRange,
|
||||||
@@ -211,8 +209,7 @@ const SummaryTab: React.FC = () => {
|
|||||||
const access = useAccess();
|
const access = useAccess();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const t = (id: string) => intl.formatMessage({ id });
|
const t = (id: string) => intl.formatMessage({ id });
|
||||||
// One vivid primary per summary card (Tokens / Compute / Storage).
|
const coolColors = useCoolColors()(8);
|
||||||
const coolColors = useCoolAccents()(3);
|
|
||||||
|
|
||||||
// No All/My dropdown (matches the Tokens tab): managers see the org-wide
|
// No All/My dropdown (matches the Tokens tab): managers see the org-wide
|
||||||
// view and narrow it with the user filter, others only their own rows.
|
// view and narrow it with the user filter, others only their own rows.
|
||||||
@@ -233,38 +230,7 @@ const SummaryTab: React.FC = () => {
|
|||||||
selectedUsers: []
|
selectedUsers: []
|
||||||
});
|
});
|
||||||
const { start, end, selectedUsers } = queryParams;
|
const { start, end, selectedUsers } = queryParams;
|
||||||
const { creators: resourceUsers } = useResourceMeta(scope);
|
const { creators: userOptions } = useResourceMeta(scope);
|
||||||
const { detailData: tokenMeta, fetchData: fetchTokenMeta } =
|
|
||||||
useQueryUsageMetaData();
|
|
||||||
|
|
||||||
// The user filter unions two sources: resource creators (GPU / storage
|
|
||||||
// usage) and the token-usage users (/usage/meta) — a user may appear in only
|
|
||||||
// one. Deduped by user id. The token meta also carries the per-user identity
|
|
||||||
// the token-series endpoint filters on (see ``tokenUserById``).
|
|
||||||
const userOptions = useMemo<SelectOption[]>(() => {
|
|
||||||
const map = new Map<number, SelectOption>();
|
|
||||||
resourceUsers.forEach((u) =>
|
|
||||||
map.set(u.value, { value: u.value, label: u.label, deleted: u.deleted })
|
|
||||||
);
|
|
||||||
(tokenMeta?.users || []).forEach((u) => {
|
|
||||||
const id = u.identity.current?.user_id;
|
|
||||||
if (id != null && !map.has(id)) {
|
|
||||||
map.set(id, { value: id, label: u.label });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(map.values());
|
|
||||||
}, [resourceUsers, tokenMeta]);
|
|
||||||
|
|
||||||
// user id → the identity object the token series filters by. Built from the
|
|
||||||
// token meta so the trend's ``users`` filter carries the real identity.
|
|
||||||
const tokenUserById = useMemo(() => {
|
|
||||||
const map = new Map<number, FilterOptionType>();
|
|
||||||
(tokenMeta?.users || []).forEach((u) => {
|
|
||||||
const id = u.identity.current?.user_id;
|
|
||||||
if (id != null) map.set(id, { identity: u.identity });
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [tokenMeta]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
detailData: summary,
|
detailData: summary,
|
||||||
@@ -318,24 +284,6 @@ const SummaryTab: React.FC = () => {
|
|||||||
? { creator_ids: currentParams.selectedUsers }
|
? { creator_ids: currentParams.selectedUsers }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// The token series hits /usage/breakdown, which filters users by identity
|
|
||||||
// rather than the creator_ids the resource endpoints take — so the token
|
|
||||||
// trend honors the user filter like the totals do. Resolve each id to its
|
|
||||||
// token-meta identity, falling back to a minimal current.user_id object for
|
|
||||||
// users present only in the resource meta.
|
|
||||||
const tokenUserFilter: { users?: FilterOptionType[] } = currentParams
|
|
||||||
.selectedUsers.length
|
|
||||||
? {
|
|
||||||
users: currentParams.selectedUsers.map(
|
|
||||||
(id) =>
|
|
||||||
tokenUserById.get(id) ??
|
|
||||||
({
|
|
||||||
identity: { current: { user_id: id } }
|
|
||||||
} as unknown as FilterOptionType)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
: {};
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchSummary({
|
fetchSummary({
|
||||||
...commonParams,
|
...commonParams,
|
||||||
@@ -350,21 +298,16 @@ const SummaryTab: React.FC = () => {
|
|||||||
filters: creatorFilter
|
filters: creatorFilter
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Date-bucketed trends: fetch the whole series via the no-pagination
|
|
||||||
// sentinel (page: -1). A metric-desc page would drop low-traffic (often
|
|
||||||
// most recent) buckets and leave gaps in the chart.
|
|
||||||
fetchTokenSeries({
|
fetchTokenSeries({
|
||||||
...commonParams,
|
...commonParams,
|
||||||
metric: 'total_tokens',
|
metric: 'total_tokens',
|
||||||
group_by: ['date'],
|
group_by: ['date'],
|
||||||
granularity,
|
granularity,
|
||||||
page: -1,
|
filters: {}
|
||||||
filters: tokenUserFilter
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
fetchComputeBreakdown({
|
fetchComputeBreakdown({
|
||||||
...paginationParams,
|
...paginationParams,
|
||||||
page: -1,
|
|
||||||
group_by: ['date'],
|
group_by: ['date'],
|
||||||
granularity,
|
granularity,
|
||||||
filters: creatorFilter
|
filters: creatorFilter
|
||||||
@@ -372,7 +315,6 @@ const SummaryTab: React.FC = () => {
|
|||||||
|
|
||||||
fetchStorageByDate({
|
fetchStorageByDate({
|
||||||
...paginationParams,
|
...paginationParams,
|
||||||
page: -1,
|
|
||||||
group_by: ['date'],
|
group_by: ['date'],
|
||||||
granularity,
|
granularity,
|
||||||
filters: creatorFilter
|
filters: creatorFilter
|
||||||
@@ -469,7 +411,6 @@ const SummaryTab: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTokenMeta();
|
|
||||||
fetchAll();
|
fetchAll();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -558,13 +499,13 @@ const SummaryTab: React.FC = () => {
|
|||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<DomainSection
|
<DomainSection
|
||||||
title={t('usage.tabs.storage')}
|
title={t('usage.tabs.storage')}
|
||||||
accent={coolColors[2]}
|
accent={coolColors[3]}
|
||||||
donutData={storageDonut}
|
donutData={storageDonut}
|
||||||
donutTotalLabel={t('usage.metric.gbDays')}
|
donutTotalLabel={t('usage.metric.gbDays')}
|
||||||
trendTitle={t('usage.summary.gbDaysOverTime')}
|
trendTitle={t('usage.summary.gbDaysOverTime')}
|
||||||
trendXAxis={storageTrend.xAxis}
|
trendXAxis={storageTrend.xAxis}
|
||||||
trendData={storageTrend.data}
|
trendData={storageTrend.data}
|
||||||
trendColor={coolColors[2]}
|
trendColor={coolColors[3]}
|
||||||
trendGran={granularity}
|
trendGran={granularity}
|
||||||
pieLoading={storageByTypeLoading}
|
pieLoading={storageByTypeLoading}
|
||||||
barLoading={storageByDateLoading}
|
barLoading={storageByDateLoading}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { BreakdownFilters } from '../../config/types';
|
import { UsageFilterItem } from '../../config/types';
|
||||||
import ApiKeysTable from '../tables/apikeys-table';
|
import ApiKeysTable from '../tables/apikeys-table';
|
||||||
import ModelsTable from '../tables/models-table';
|
import ModelsTable from '../tables/models-table';
|
||||||
import UsersTable from '../tables/users-table';
|
import UsersTable from '../tables/users-table';
|
||||||
|
|
||||||
|
type FilterOptionType = Omit<UsageFilterItem, 'label' | 'deleted'>;
|
||||||
|
const EMPTY_FILTERS: FilterOptionType[] = [];
|
||||||
|
|
||||||
const BreakdownTabs: React.FC<{
|
const BreakdownTabs: React.FC<{
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start_date: string;
|
start_date: string;
|
||||||
@@ -14,9 +17,16 @@ const BreakdownTabs: React.FC<{
|
|||||||
scope: string;
|
scope: string;
|
||||||
pageResetKey?: number;
|
pageResetKey?: number;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
filters: BreakdownFilters;
|
filters: {
|
||||||
|
routes?: FilterOptionType[];
|
||||||
|
users?: FilterOptionType[];
|
||||||
|
api_keys?: FilterOptionType[];
|
||||||
|
};
|
||||||
}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
|
}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const routes = filters.routes || EMPTY_FILTERS;
|
||||||
|
const users = filters.users || EMPTY_FILTERS;
|
||||||
|
const apiKeys = filters.api_keys || EMPTY_FILTERS;
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -27,7 +37,7 @@ const BreakdownTabs: React.FC<{
|
|||||||
children: (
|
children: (
|
||||||
<ModelsTable
|
<ModelsTable
|
||||||
key="models"
|
key="models"
|
||||||
filters={filters}
|
routes={routes}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
pageResetKey={pageResetKey}
|
pageResetKey={pageResetKey}
|
||||||
@@ -42,7 +52,7 @@ const BreakdownTabs: React.FC<{
|
|||||||
children: (
|
children: (
|
||||||
<UsersTable
|
<UsersTable
|
||||||
key="users"
|
key="users"
|
||||||
filters={filters}
|
users={users}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
pageResetKey={pageResetKey}
|
pageResetKey={pageResetKey}
|
||||||
@@ -57,7 +67,7 @@ const BreakdownTabs: React.FC<{
|
|||||||
children: (
|
children: (
|
||||||
<ApiKeysTable
|
<ApiKeysTable
|
||||||
key="api_keys"
|
key="api_keys"
|
||||||
filters={filters}
|
apiKeys={apiKeys}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
pageResetKey={pageResetKey}
|
pageResetKey={pageResetKey}
|
||||||
@@ -71,7 +81,7 @@ const BreakdownTabs: React.FC<{
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [filters, dateRange, pageResetKey, refreshKey, scope]);
|
}, [apiKeys, dateRange, routes, pageResetKey, refreshKey, scope, users]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ import PageBox from '@/pages/_components/page-box';
|
|||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Table } from 'antd';
|
import { Table } from 'antd';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { BreakdownFilters } from '../../config/types';
|
import { FilterOptionType } from '../../config/types';
|
||||||
import useAPIKeys from '../../hooks/use-apikeys-columns';
|
import useAPIKeys from '../../hooks/use-apikeys-columns';
|
||||||
import useQueryBreakdownList from '../../services/use-query-breakdown-list';
|
import useQueryBreakdownList from '../../services/use-query-breakdown-list';
|
||||||
import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
|
import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
|
||||||
|
|
||||||
const APIKeys: React.FC<{
|
const APIKeys: React.FC<{
|
||||||
filters: BreakdownFilters;
|
apiKeys: FilterOptionType[];
|
||||||
dateRange: { start_date: string; end_date: string };
|
dateRange: { start_date: string; end_date: string };
|
||||||
scope: string;
|
scope: string;
|
||||||
pageResetKey?: number;
|
pageResetKey?: number;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
|
}> = ({ apiKeys, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { loading, dataSource, fetchData } = useQueryBreakdownList({
|
const { loading, dataSource, fetchData } = useQueryBreakdownList({
|
||||||
@@ -69,16 +69,16 @@ const APIKeys: React.FC<{
|
|||||||
fetchData({
|
fetchData({
|
||||||
...queryParams,
|
...queryParams,
|
||||||
group_by: ['api_key'],
|
group_by: ['api_key'],
|
||||||
// Send the full filter set (route / user / api_key), not just the
|
filters: {
|
||||||
// table's own dimension, so the breakdown matches the trend chart.
|
api_keys: apiKeys
|
||||||
filters,
|
},
|
||||||
scope: scope,
|
scope: scope,
|
||||||
...dateRange
|
...dateRange
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
|
apiKeys,
|
||||||
dateRange.end_date,
|
dateRange.end_date,
|
||||||
dateRange.start_date,
|
dateRange.start_date,
|
||||||
filters,
|
|
||||||
queryParams.page,
|
queryParams.page,
|
||||||
queryParams.perPage,
|
queryParams.perPage,
|
||||||
queryParams.sort_by,
|
queryParams.sort_by,
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ import PageBox from '@/pages/_components/page-box';
|
|||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Table } from 'antd';
|
import { Table } from 'antd';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { BreakdownFilters, BreakdownItem } from '../../config/types';
|
import { BreakdownItem, FilterOptionType } from '../../config/types';
|
||||||
import useModelsColumns from '../../hooks/use-models-columns';
|
import useModelsColumns from '../../hooks/use-models-columns';
|
||||||
import useQueryBreakdownList from '../../services/use-query-breakdown-list';
|
import useQueryBreakdownList from '../../services/use-query-breakdown-list';
|
||||||
import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
|
import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
|
||||||
|
|
||||||
const Models: React.FC<{
|
const Models: React.FC<{
|
||||||
filters: BreakdownFilters;
|
routes: FilterOptionType[];
|
||||||
dateRange: { start_date: string; end_date: string };
|
dateRange: { start_date: string; end_date: string };
|
||||||
scope: string;
|
scope: string;
|
||||||
pageResetKey?: number;
|
pageResetKey?: number;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
|
}> = ({ routes, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { loading, dataSource, fetchData } = useQueryBreakdownList({
|
const { loading, dataSource, fetchData } = useQueryBreakdownList({
|
||||||
@@ -33,6 +33,7 @@ const Models: React.FC<{
|
|||||||
const pendingPageResetRef = useRef(false);
|
const pendingPageResetRef = useRef(false);
|
||||||
|
|
||||||
const handleTableChange = (pagination: any, filters: any, sorter: any) => {
|
const handleTableChange = (pagination: any, filters: any, sorter: any) => {
|
||||||
|
console.log('pagination, filters, sorter: ', pagination, filters, sorter);
|
||||||
const sort_by =
|
const sort_by =
|
||||||
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
|
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
|
||||||
setQueryParams((prev) => ({
|
setQueryParams((prev) => ({
|
||||||
@@ -71,16 +72,16 @@ const Models: React.FC<{
|
|||||||
fetchData({
|
fetchData({
|
||||||
...queryParams,
|
...queryParams,
|
||||||
group_by: ['route'],
|
group_by: ['route'],
|
||||||
// Send the full filter set (route / user / api_key), not just the
|
filters: {
|
||||||
// table's own dimension, so the breakdown matches the trend chart.
|
routes
|
||||||
filters,
|
},
|
||||||
scope: scope,
|
scope: scope,
|
||||||
...dateRange
|
...dateRange
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
dateRange.end_date,
|
dateRange.end_date,
|
||||||
dateRange.start_date,
|
dateRange.start_date,
|
||||||
filters,
|
routes,
|
||||||
queryParams.page,
|
queryParams.page,
|
||||||
queryParams.perPage,
|
queryParams.perPage,
|
||||||
queryParams.sort_by,
|
queryParams.sort_by,
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ import PageBox from '@/pages/_components/page-box';
|
|||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Table } from 'antd';
|
import { Table } from 'antd';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { BreakdownFilters } from '../../config/types';
|
import { FilterOptionType } from '../../config/types';
|
||||||
import useUsersColumns from '../../hooks/use-users-columns';
|
import useUsersColumns from '../../hooks/use-users-columns';
|
||||||
import useQueryBreakdownList from '../../services/use-query-breakdown-list';
|
import useQueryBreakdownList from '../../services/use-query-breakdown-list';
|
||||||
import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
|
import getBreakdownRowKey from '../../utils/get-breakdown-row-key';
|
||||||
|
|
||||||
const Users: React.FC<{
|
const Users: React.FC<{
|
||||||
filters: BreakdownFilters;
|
users: FilterOptionType[];
|
||||||
dateRange: { start_date: string; end_date: string };
|
dateRange: { start_date: string; end_date: string };
|
||||||
scope: string;
|
scope: string;
|
||||||
pageResetKey?: number;
|
pageResetKey?: number;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
}> = ({ filters, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
|
}> = ({ users, dateRange, scope, pageResetKey = 0, refreshKey = 0 }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { loading, dataSource, fetchData } = useQueryBreakdownList({
|
const { loading, dataSource, fetchData } = useQueryBreakdownList({
|
||||||
@@ -71,9 +71,9 @@ const Users: React.FC<{
|
|||||||
fetchData({
|
fetchData({
|
||||||
...queryParams,
|
...queryParams,
|
||||||
group_by: ['user'],
|
group_by: ['user'],
|
||||||
// Send the full filter set (route / user / api_key), not just the
|
filters: {
|
||||||
// table's own dimension, so the breakdown matches the trend chart.
|
users
|
||||||
filters,
|
},
|
||||||
scope: scope,
|
scope: scope,
|
||||||
...dateRange
|
...dateRange
|
||||||
});
|
});
|
||||||
@@ -81,12 +81,12 @@ const Users: React.FC<{
|
|||||||
}, [
|
}, [
|
||||||
dateRange.end_date,
|
dateRange.end_date,
|
||||||
dateRange.start_date,
|
dateRange.start_date,
|
||||||
filters,
|
|
||||||
queryParams.page,
|
queryParams.page,
|
||||||
queryParams.perPage,
|
queryParams.perPage,
|
||||||
queryParams.sort_by,
|
queryParams.sort_by,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
scope
|
scope,
|
||||||
|
users
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,39 +16,3 @@ import { ResourceBreakdownItem } from '../apis/resource';
|
|||||||
export const instanceTypeLabel = (
|
export const instanceTypeLabel = (
|
||||||
row?: Partial<ResourceBreakdownItem>
|
row?: Partial<ResourceBreakdownItem>
|
||||||
): string => row?.product || row?.gpu_type || '-';
|
): string => row?.product || row?.gpu_type || '-';
|
||||||
|
|
||||||
const _trim = (n: number): string =>
|
|
||||||
Number.isInteger(n) ? `${n}` : n.toFixed(1);
|
|
||||||
|
|
||||||
// Compact CPU/RAM spec, e.g. "2 vCPU · 4 GB", from the instance totals
|
|
||||||
// (millicores / MiB). Empty string when neither is known.
|
|
||||||
export const formatCpuSpec = (
|
|
||||||
cpuMilli?: number | null,
|
|
||||||
memMib?: number | null
|
|
||||||
): string => {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (cpuMilli) parts.push(`${_trim(cpuMilli / 1000)} vCPU`);
|
|
||||||
if (memMib) parts.push(`${_trim(memMib / 1024)} GB`);
|
|
||||||
return parts.join(' · ');
|
|
||||||
};
|
|
||||||
|
|
||||||
// CPU instance-type label: "CPU Only" plus its real size when known, e.g.
|
|
||||||
// "CPU Only · 2 vCPU · 4 GB". Used by both the table column and the trend
|
|
||||||
// legend so they read identically.
|
|
||||||
export const cpuOnlyLabel = (row?: Partial<ResourceBreakdownItem>): string => {
|
|
||||||
const spec = formatCpuSpec(row?.cpu_milli, row?.memory_mib);
|
|
||||||
return spec ? `CPU Only · ${spec}` : 'CPU Only';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Instance Types are grouped by actual shape, so each row is one concrete
|
|
||||||
// type: a GPU shows "<product> x <cards>", a CPU shows "CPU Only · <spec>".
|
|
||||||
// One label for the table column and the trend legend so they read the same
|
|
||||||
// and each shape is a distinct series. (" x " matches the GPU Instances list.)
|
|
||||||
export const instanceTypeSeriesLabel = (
|
|
||||||
row?: Partial<ResourceBreakdownItem>
|
|
||||||
): string => {
|
|
||||||
const isCpu = !row?.gpu_count && !row?.vram_mib;
|
|
||||||
if (isCpu) return cpuOnlyLabel(row);
|
|
||||||
const product = row?.product || row?.gpu_type || '-';
|
|
||||||
return row?.gpu_count ? `${product} x ${row.gpu_count}` : product;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -53,12 +53,7 @@ export const buildTrendSeries = (opts: {
|
|||||||
byGroup.set(label, new Map());
|
byGroup.set(label, new Map());
|
||||||
order.push(label);
|
order.push(label);
|
||||||
}
|
}
|
||||||
// Sum rather than overwrite: should two backend groups ever map to the same
|
byGroup.get(label)!.set(bucketKey(i.date, granularity), valueOf(i, metric));
|
||||||
// display label, several rows can share a (label, bucket), so accumulate
|
|
||||||
// instead of letting the last write win.
|
|
||||||
const bucket = byGroup.get(label)!;
|
|
||||||
const key = bucketKey(i.date, granularity);
|
|
||||||
bucket.set(key, (bucket.get(key) ?? 0) + valueOf(i, metric));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const colors = palette(Math.max(order.length, 1));
|
const colors = palette(Math.max(order.length, 1));
|
||||||
|
|||||||
@@ -20,15 +20,13 @@ export interface LoginKit {
|
|||||||
};
|
};
|
||||||
useSSOAuth: (opts: any) => {
|
useSSOAuth: (opts: any) => {
|
||||||
options: {
|
options: {
|
||||||
// Active external auth provider (e.g. ``{type: "CAS", login_url:
|
saml: boolean;
|
||||||
// "/auth/cas/login"}``) or ``null`` when only local login is
|
oidc: boolean;
|
||||||
// configured. The login UI renders an SSO button only when this
|
|
||||||
// is non-null and navigates to ``login_url``.
|
|
||||||
external_auth: { type: string; login_url: string } | null;
|
|
||||||
first_time_setup: boolean;
|
first_time_setup: boolean;
|
||||||
get_initial_password_command: string;
|
get_initial_password_command: string;
|
||||||
};
|
};
|
||||||
loginWithExternalAuth: () => void;
|
loginWithOIDC: () => void;
|
||||||
|
loginWithSAML: () => void;
|
||||||
};
|
};
|
||||||
userInfo: any;
|
userInfo: any;
|
||||||
setUserInfo: (info: any) => void;
|
setUserInfo: (info: any) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user