Compare commits

..
6 Commits
Author SHA1 Message Date
jialin 57f4ddad26 chore: remove --port backend parameter 2025-06-05 11:29:54 +08:00
jialin de34d0ae00 fix: instances distribute info trigger by click 2025-06-05 11:24:09 +08:00
jialin 4f7037baa0 chore: reset changes on catalog 2025-06-03 18:10:39 +08:00
jialin bd15e9aa6c fix: tips for css files load failed 2025-06-03 16:28:03 +08:00
jialin 907105a492 fix: set default spec to form 2025-06-03 15:35:38 +08:00
gitlawr 0d3da04356 ci: publish v*-dev branches 2025-05-30 13:20:40 +08:00
1218 changed files with 50252 additions and 110199 deletions
-143
View File
@@ -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.
-103
View File
@@ -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
View File
@@ -1,4 +1,3 @@
PORT=9000 PORT=9000
UMI_DEV_SERVER_COMPRESS=none UMI_DEV_SERVER_COMPRESS=none
DID_YOU_KNOW=none
+1
View File
@@ -0,0 +1 @@
src/components/icon-font/iconfont/iconfont.js
+15
View File
@@ -0,0 +1,15 @@
module.exports = {
extends: require.resolve('@umijs/max/eslint'),
rules: {
'react/no-unstable-nested-components': 1,
'no-unused-vars': 'off',
'no-undef': 'error',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/class-name-casing': 'off'
},
globals: {
Global: 'readonly',
React: 'readonly'
},
ignorePatterns: ['public/static/']
};
+2 -20
View File
@@ -1,7 +1,6 @@
name: CI name: CI
on: on:
workflow_dispatch: {}
push: push:
branches: branches:
- 'main' - 'main'
@@ -14,7 +13,7 @@ on:
- 'v*-dev' - 'v*-dev'
env: env:
NODE_VERSION: '22' NODE_VERSION: '21'
jobs: jobs:
deps: deps:
@@ -33,7 +32,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version: '${{ env.NODE_VERSION }}'
cache: 'pnpm' cache: 'pnpm'
- name: Deps - name: Deps
run: scripts/deps run: scripts/deps
@@ -118,20 +117,3 @@ jobs:
remote_path: releases/${{ steps.version.outputs.version }}.tar.gz remote_path: releases/${{ steps.version.outputs.version }}.tar.gz
accelerate: true accelerate: true
clean: false clean: false
trigger-backend:
needs: build-publish
if: (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-22.04
steps:
- name: Dispatch backend build
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.DISPATCH_PAT }}
repository: gpustack/gpustack
event-type: ui-built
client-payload: |
{
"ref": "${{ github.ref }}",
"sha": "${{ github.sha }}"
}
-3
View File
@@ -13,6 +13,3 @@
.swc .swc
.DS_Store .DS_Store
.idea .idea
.claude/settings.local.json
/dist.zip
.cache
+2
View File
@@ -1,2 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged npx lint-staged
+5 -3
View File
@@ -1,11 +1,13 @@
{ {
"*.{md,json}": ["prettier --cache --write"], "*.{md,json}": ["prettier --cache --write"],
"*.{js,jsx}": ["max lint --fix --eslint-only", "prettier --cache --write"], "*.{js,jsx}": ["max lint --fix --eslint-only", "prettier --cache --write"],
"*.{css,less}": ["prettier --cache --write"], "*.{css,less}": [
"!public/vs/**": [], "max lint --fix --stylelint-only",
"prettier --cache --write"
],
"*.ts?(x)": [ "*.ts?(x)": [
"max lint --fix --eslint-only", "max lint --fix --eslint-only",
"prettier --cache --parser=typescript --write" "prettier --cache --parser=typescript --write"
], ],
"src/locales/**/*.ts": ["node --import tsx src/locales/check.ts"] "src/locales/**/*.ts": ["npx tsx src/locales/check.ts"]
} }
-1
View File
@@ -1 +0,0 @@
22
+2 -1
View File
@@ -3,6 +3,7 @@ node_modules
.umi-production .umi-production
public/static/*.js public/static/*.js
public/static/*.css public/static/*.css
src/components/iconfont/ src/components/icon-font/iconfont/iconfont.js
src/components/icon-font/iconfont/*.css
+1 -1
View File
@@ -3,5 +3,5 @@ module.exports = {
rules: { rules: {
'selector-class-pattern': null 'selector-class-pattern': null
}, },
ignoreFiles: ['public/static/*.css', 'src/components/iconfont/iconfont.css'] ignoreFiles: ['public/static/*.css']
}; };
-5
View File
@@ -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
-140
View File
@@ -1,140 +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.
## Layout
Compose layout with Ant components, not hand-written `display: flex`.
- **1D flex** (row/column with `gap`, `align`, `justify`) → `Flex`. Do not write raw `display: flex` in new code.
- **Inline sequence** of a few elements with uniform spacing → `Space`.
- **Page/grid columns** → `Row` / `Col`.
Drive spacing with the theme scale (`Flex`/`Space` `gap`, or `var(--ant-*)` spacing tokens), not scattered `px` literals.
# Naming conventions
A page module lives under `src/pages/{module}` with this sub-structure: `components/`, `config/`, `forms/`, `hooks/`, `services/`, `index.tsx`. File naming:
- **Create/edit modal**: `add-{feature}-modal.tsx` (keep the `-modal` suffix even when built with `FormDrawer`).
- **Table columns hook**: `use-{feature}-columns.tsx`.
- **Open/close & request hooks**: `use-{verb}-{noun}.ts` (e.g. `use-create-user.ts`, `use-query-user-list.ts`).
- **Complex table cell**: extract into `{feature}-cell.tsx`.
# Config & types
- `config/types.ts` — TypeScript types. Form shape → `FormData`; table/list row → `ListItem`.
- `config/index.ts` — static constants, enums, and value/label maps (e.g. `XxxStatusValueMap`, `XxxStatusLabelMap`). Keep constants out of `types.ts`.
- **`Select` options that need i18n**: set `label` to the message key and add `locale: true` on the option — the field translates it at render. Omit `locale` for options whose label is already final text. Ref `src/pages/benchmark/config/index.ts`.
# Common components
Always check `@gpustack/core-ui` first. Frequently reused:
- **Drawer/Modal open/close**: `useBodyScroll`.
- **Form drawer / footer**: `FormDrawer`, `ModalFooter`.
- **Delete confirmation**: `DeleteModal`.
- **Search + bulk actions bar**: `FilterBar`.
- **Form fields**: `BaseSelect`, `Input` (labeled).
- **Text overflow**: `AutoTooltip`.
- **Icons**: `IconFont`.
- **Status display** (success/failed/processing/warning): `StatusTag`.
- **Permission-gated visibility**: `Access` / `useAccess`.
- **Request hooks**: `useRequest` / `useQueryData` / `useQueryDataList`.
- **Table data fetching**: `useTableFetch`.
- **Submit guard** (prevent double-submit): `useSubmitLock`.
- **Tabbed forms**: `ScrollSpyTabs`.
# Dynamic add-item form fields
When building a form, select the add-item component from the **shape of the field's data** (its schema). Match the schema, don't hand-roll a list UI:
- **Plain object** (key→value map) → `LabelSelector`.
- **String array** → `ListInput`. Ref `src/pages/llmodels/forms/backend-parameters-list.tsx`.
- **Object array** → `MetadataList` with a custom item renderer per entry. Ref `src/pages/llmodels/forms/model-lora-list.tsx`.
+19 -19
View File
@@ -1,10 +1,9 @@
import { defineConfig } from '@umijs/max'; import { defineConfig } from '@umijs/max';
import keepAlive from './keep-alive'; import keepAlive from './keep-alive';
import { extraMfsuExclude } from './mfsu.extensions';
import { compressionPluginConfig, monacoPluginConfig } from './plugins';
import proxy from './proxy'; import proxy from './proxy';
import routes from './routes'; import routes from './routes';
import { getBranchInfo } from './utils'; import { getBranchInfo } from './utils';
const CompressionWebpackPlugin = require('compression-webpack-plugin');
const versionInfo = getBranchInfo(); const versionInfo = getBranchInfo();
process.env.VERSION = JSON.stringify(versionInfo); process.env.VERSION = JSON.stringify(versionInfo);
@@ -20,9 +19,6 @@ export default defineConfig({
history: { history: {
type: 'hash' type: 'hash'
}, },
define: {
'process.env.ENABLE_ENTERPRISE': process.env.ENABLE_ENTERPRISE
},
analyze: { analyze: {
analyzerMode: 'server', analyzerMode: 'server',
analyzerPort: 8888, analyzerPort: 8888,
@@ -32,9 +28,6 @@ export default defineConfig({
logLevel: 'info', logLevel: 'info',
defaultSizes: 'parsed' // stat // gzip defaultSizes: 'parsed' // stat // gzip
}, },
mfsu: {
exclude: ['lodash', 'ml-pca', ...extraMfsuExclude]
},
base: process.env.npm_config_base || '/', base: process.env.npm_config_base || '/',
...(isProduction ...(isProduction
? { ? {
@@ -57,18 +50,29 @@ export default defineConfig({
chunkFilename: `css/[name].${t}.chunk.css` chunkFilename: `css/[name].${t}.chunk.css`
} }
]); ]);
config.module
.rule('worker')
.test(/\.worker\.js$/)
.use('worker-loader')
.loader('worker-loader');
config.output config.output
.filename(`js/[name].${t}.js`) .filename(`js/[name].${t}.js`)
.chunkFilename(`js/[name].${t}.chunk.js`); .chunkFilename(`js/[name].${t}.chunk.js`);
compressionPluginConfig(config); config
monacoPluginConfig(config); .plugin('compression-webpack-plugin')
.use(CompressionWebpackPlugin, [
{
filename: '[path][base].gz',
algorithm: 'gzip',
test: /\.(js|css|html|svg)$/,
threshold: 10240,
minRatio: 0.8
}
]);
} }
} }
: { : {}),
chainWebpack(config) {
monacoPluginConfig(config);
}
}),
favicons: ['/static/favicon.png'], favicons: ['/static/favicon.png'],
jsMinifier: 'terser', jsMinifier: 'terser',
cssMinifier: 'cssnano', cssMinifier: 'cssnano',
@@ -77,15 +81,11 @@ export default defineConfig({
antd: { antd: {
style: 'less' style: 'less'
}, },
title: 'GPUStack',
hash: true, hash: true,
access: {}, access: {},
model: {}, model: {},
initialState: {}, initialState: {},
request: {}, request: {},
routePrefetch: {
defaultPrefetch: 'intent'
},
keepalive: keepAlive, keepalive: keepAlive,
locale: { locale: {
antd: true, antd: true,
-9
View File
@@ -1,9 +0,0 @@
// Identity hook for build-time mfsu.exclude extensions. Tooling may
// overwrite this file to add package names that must skip MFSU's
// pre-bundling; the original is restored on cleanup. Mirrors
// `src/request.extensions.ts` / `src/access.extensions.ts`.
//
// MFSU bundles node_modules into immutable chunks at dev startup, so
// workspace-linked packages whose source you edit during dev must be
// excluded here or HMR won't pick up changes.
export const extraMfsuExclude: string[] = [];
-36
View File
@@ -1,36 +0,0 @@
import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin';
const CompressionWebpackPlugin = require('compression-webpack-plugin');
export function monacoPluginConfig(config: any) {
return config
.plugin('monaco-editor-webpack-plugin')
.use(MonacoWebpackPlugin, [
{
languages: ['yaml'],
customLanguages: [
{
label: 'yaml',
entry: 'monaco-yaml',
worker: {
id: 'monaco-yaml/yamlWorker',
entry: 'monaco-yaml/yaml.worker.js'
}
}
]
}
]);
}
export function compressionPluginConfig(config: any) {
return config
.plugin('compression-webpack-plugin')
.use(CompressionWebpackPlugin, [
{
filename: '[path][base].gz',
algorithm: 'gzip',
test: /\.(js|css|html|svg)$/,
threshold: 10240,
minRatio: 0.8
}
]);
}
+7 -4
View File
@@ -1,13 +1,11 @@
const proxyTableList = [ const proxyTableList = [
'cli', 'cli',
'v1', 'v1',
'v2',
'auth', 'auth',
'v1-openai', 'v1-openai',
'version', 'version',
'proxy', 'proxy',
'update', 'update'
'grafana'
]; ];
// @ts-ingore // @ts-ingore
@@ -22,6 +20,12 @@ export default function createProxyTable(target?: string) {
ws: true, ws: true,
log: 'debug', log: 'debug',
pathRewrite: (pth: string) => pth.replace(`/^/${api}`, `/${api}`), pathRewrite: (pth: string) => pth.replace(`/^/${api}`, `/${api}`),
// onProxyRes: (proxyRes: any, req: any, res: any) => {
// console.log('headers=========', {
// res: proxyRes.headers,
// req: req.headers
// });
// },
headers: { headers: {
origin: newTarget, origin: newTarget,
Connection: 'keep-alive' Connection: 'keep-alive'
@@ -31,6 +35,5 @@ export default function createProxyTable(target?: string) {
}, },
{} {}
); );
return proxyTable; return proxyTable;
} }
-3
View File
@@ -1,3 +0,0 @@
// Identity hook for build-time route extensions. Tooling may overwrite
// this file to inject additional routes; the original is restored on cleanup.
export const applyRouteExtensions = <T>(base: T): T => base;
+46 -357
View File
@@ -1,26 +1,17 @@
import { keepAliveRoutes } from './keep-alive'; import { keepAliveRoutes } from './keep-alive';
import { applyRouteExtensions } from './routes.extensions';
const baseRoutes = [ export default [
{ {
name: 'dashboard', name: 'dashboard',
path: '/dashboard', path: '/dashboard',
key: 'dashboard', key: 'dashboard',
icon: 'icon-dashboard', icon: 'AppstoreOutlined',
selectedIcon: 'icon-dashboard-filled', access: 'canSeeAdmin',
defaultIcon: 'icon-dashboard', component: './dashboard'
// `canSeeOrgAdmin` widens to anyone the access seam grants
// admin-ish visibility — by default platform admin, plus
// whatever the routes extension chooses to allow.
access: 'canSeeOrgAdmin',
component: './dashboard',
routes: []
}, },
{ {
name: 'playground', name: 'playground',
icon: 'icon-experiment', icon: 'ExperimentOutlined',
selectedIcon: 'icon-experiment-filled',
defaultIcon: 'icon-experiment',
path: '/playground', path: '/playground',
key: 'playground', key: 'playground',
routes: [ routes: [
@@ -33,386 +24,86 @@ const baseRoutes = [
title: 'Chat', title: 'Chat',
path: '/playground/chat', path: '/playground/chat',
key: 'chat', key: 'chat',
icon: 'icon-chat', icon: 'Comment',
selectedIcon: 'icon-chat-filled', component: './playground/index'
defaultIcon: 'icon-chat',
component: './playground/chat/index'
},
{
name: 'embedding',
title: 'embedding',
path: '/playground/embedding',
key: 'embedding',
icon: 'icon-embedding',
selectedIcon: 'icon-embedding-filled',
defaultIcon: 'icon-embedding',
component: './playground/embedding/index'
},
{
name: 'rerank',
title: 'Rerank',
path: '/playground/rerank',
key: 'rerank',
icon: 'icon-reranker',
selectedIcon: 'icon-reranker-filled',
defaultIcon: 'icon-reranker',
component: './playground/rerank/index'
}, },
{ {
name: 'text2images', name: 'text2images',
title: 'Text2Images', title: 'Text2Images',
path: keepAliveRoutes.text2images, path: keepAliveRoutes.text2images,
key: 'text2images', key: 'text2images',
icon: 'icon-image1', icon: 'Comment',
selectedIcon: 'icon-image-filled', component: './playground/images'
defaultIcon: 'icon-image1',
component: './playground/images/index'
}, },
{ {
name: 'speech', name: 'speech',
title: 'Speech', title: 'Speech',
path: keepAliveRoutes.speech, path: keepAliveRoutes.speech,
key: 'speech', key: 'speech',
icon: 'icon-audio1', icon: 'Comment',
selectedIcon: 'icon-audio-filled', component: './playground/speech'
defaultIcon: 'icon-audio1',
component: './playground/speech/index'
}
// {
// name: 'video',
// title: 'Video',
// path: '/playground/video',
// key: 'video',
// icon: 'icon-video-outline',
// hideInMenu: false,
// selectedIcon: 'icon-video-filled02',
// defaultIcon: 'icon-video-outline',
// component: './playground/video'
// }
]
}, },
{ {
name: 'models', name: 'embedding',
path: '/models', title: 'embedding',
key: 'models', path: '/playground/embedding',
routes: [ key: 'embedding',
icon: 'Comment',
component: './playground/embedding'
},
{ {
path: '/models', name: 'rerank',
redirect: '/models/deployments' title: 'Rerank',
path: '/playground/rerank',
key: 'rerank',
icon: 'Comment',
component: './playground/rerank'
}
]
}, },
{ {
name: 'modelCatalog', name: 'modelCatalog',
path: '/models/catalog', path: '/models/catalog',
key: 'modelsCatalog', key: 'modelsCatalog',
icon: 'icon-layers', icon: 'icon-catalog',
selectedIcon: 'icon-layers-filled', access: 'canSeeAdmin',
defaultIcon: 'icon-layers',
access: 'canSeeOrgAdmin',
component: './llmodels/catalog' component: './llmodels/catalog'
}, },
{ {
name: 'userModels', name: 'models',
path: '/models/user-models', path: '/models/list',
key: 'userModels', key: 'models',
icon: 'icon-models', icon: 'Block',
selectedIcon: 'icon-models-filled', access: 'canSeeAdmin',
defaultIcon: 'icon-models',
access: 'canSeeUser',
component: './llmodels/user-models'
},
{
name: 'deployment',
path: '/models/deployments',
key: 'modelDeployments',
icon: 'icon-rocket-launch1',
selectedIcon: 'icon-rocket-launch-fill',
defaultIcon: 'icon-rocket-launch1',
access: 'canSeeOrgAdmin',
component: './llmodels/index' component: './llmodels/index'
}, },
{
name: 'routes',
path: '/models/routes',
key: 'routes',
icon: 'icon-captive_portal',
selectedIcon: 'icon-captive_portal',
defaultIcon: 'icon-captive_portal',
access: 'canSeeOrgAdmin',
component: './model-routes/index'
},
{
name: 'providers',
path: '/models/providers',
key: 'modelProviders',
icon: 'icon-extension-outline',
selectedIcon: 'icon-extension-filled',
defaultIcon: 'icon-extension-outline',
access: 'canSeeOrgAdmin',
component: './maas-provider/index'
},
{
name: 'benchmark',
path: '/models/benchmark',
key: 'benchmark',
icon: 'icon-speed',
selectedIcon: 'icon-speed-filled',
defaultIcon: 'icon-speed',
access: 'canSeeOrgAdmin',
component: './benchmark/index'
},
{
name: 'benchmarkDetail',
path: '/models/benchmark/detail',
key: 'benchmarkDetail',
icon: 'icon-speed',
selectedIcon: 'icon-speed-filled',
defaultIcon: 'icon-speed',
access: 'canSeeOrgAdmin',
hideInMenu: true,
component: './benchmark/details'
},
{
name: 'backendsList',
path: '/models/backends',
key: 'backendsList',
icon: 'icon-backend',
selectedIcon: 'icon-backend-filled',
defaultIcon: 'icon-backend',
access: 'canSeeOrgAdmin',
component: './backends/index'
},
{
name: 'modelfiles',
path: '/models/modelfiles',
key: 'modelfiles',
icon: 'icon-files',
selectedIcon: 'icon-files-filled',
defaultIcon: 'icon-files',
access: 'canSeeOrgAdmin',
component: './resources/components/model-files'
}
]
},
{
name: 'gpuService',
path: '/gpu-service',
key: 'gpuService',
access: 'canSeeGpuService',
routes: [
{
path: '/gpu-service',
redirect: '/gpu-service/instances'
},
{
name: 'instances',
path: '/gpu-service/instances',
key: 'gpuServiceList',
icon: 'icon-cloud-outlined',
selectedIcon: 'icon-cloud-filled',
defaultIcon: 'icon-cloud-outlined',
component: './gpu-service/instances'
},
{
name: 'templates',
path: '/gpu-service/templates',
key: 'gpuServiceTemplates',
icon: 'icon-instance-template-outlined',
selectedIcon: 'icon-instance-template-filled',
defaultIcon: 'icon-instance-template-outlined',
component: './gpu-service/templates'
},
{
name: 'storage',
path: '/gpu-service/storage',
key: 'gpuServiceStorage',
icon: 'icon-database-outlined',
selectedIcon: 'icon-database-filled',
defaultIcon: 'icon-database-outlined',
component: './gpu-service/storage'
},
{
name: 'storageTypes',
path: '/gpu-service/storage-types',
key: 'gpuServiceStorageTypes',
icon: 'icon-storage-outlined',
// Storage types are tenant-scoped on the backend (Org owners
// can create/list their own), so the menu shouldn't be
// platform-admin-only. ``canSeeOrgAdmin`` keeps the gate at
// "admin or current-org owner" — Org members still don't see
// it, which matches the read/write model in the route.
access: 'canSeeOrgAdmin',
selectedIcon: 'icon-storage-filled',
defaultIcon: 'icon-storage-outlined',
component: './gpu-service/storage-types'
},
{
name: 'publicKeys',
path: '/gpu-service/public-keys',
key: 'gpuServicePublicKeys',
icon: 'icon-ssh-outlined',
selectedIcon: 'icon-ssh-filled',
defaultIcon: 'icon-ssh-outlined',
component: './gpu-service/public-keys'
}
]
},
{ {
name: 'resources', name: 'resources',
path: '/resources', path: '/resources',
key: 'resources', key: 'resources',
access: 'canSeeOrgAdmin', icon: 'CloudServer',
routes: [
{
path: '/resources',
redirect: '/resources/workers'
},
{
name: 'clusters',
path: '/resources/clusters/list',
key: 'clusters',
icon: 'icon-cluster2-outline',
selectedIcon: 'icon-cluster2-filled',
defaultIcon: 'icon-cluster2-outline',
component: './cluster-management/clusters',
subMenu: ['/resources/clusters/detail', '/resources/clusters/create']
},
{
name: 'workers',
path: '/resources/workers',
key: 'workers',
icon: 'icon-resources',
selectedIcon: 'icon-resources-filled',
defaultIcon: 'icon-resources',
component: './resources/components/workers'
},
{
name: 'gpus',
path: '/resources/gpus',
key: 'gpus',
icon: 'icon-gpu1',
selectedIcon: 'icon-gpu-filled',
defaultIcon: 'icon-gpu1',
component: './resources/components/gpus'
},
{
name: 'credentials',
path: '/resources/credentials',
key: 'credentials',
icon: 'icon-credential-outline',
selectedIcon: 'icon-credential-filled',
defaultIcon: 'icon-credential-outline',
component: './cluster-management/credentials'
},
{
name: 'clusterDetail',
path: '/resources/clusters/detail',
key: 'clusterDetail',
icon: 'icon-cluster2-outline',
selectedIcon: 'icon-cluster2-filled',
defaultIcon: 'icon-cluster2-outline',
hideInMenu: true,
component: './cluster-management/cluster-detail'
}
]
},
{
// Cross-resource consumption (tokens + GPU/CPU instances + storage).
// A folder so it matches the other top-level groups; more usage views can
// graduate in here later.
name: 'billingAndUsage',
path: '/usage',
key: 'usageGroup',
icon: 'icon-usage-outlined',
selectedIcon: 'icon-usage-filled',
defaultIcon: 'icon-usage-outlined',
routes: [
{
path: '/usage',
redirect: '/usage/overview'
},
{
name: 'usage',
path: '/usage/overview',
key: 'usage',
icon: 'icon-usage-outlined',
selectedIcon: 'icon-usage-filled',
defaultIcon: 'icon-usage-outlined',
component: './usage/index'
},
{
name: 'billing',
path: '/usage/billing',
key: 'billing',
icon: 'icon-billing-outlined',
selectedIcon: 'icon-billing-filled',
defaultIcon: 'icon-billing-outlined',
hideInMenu: process.env.ENABLE_ENTERPRISE === 'true',
// OSS exposes the menu as a teaser for the enterprise billing
// module. The page itself just renders an upsell notice — the real
// billing UI lives in the enterprise plugin and shadows this route
// via `routes.extensions.ts`.
component: './billing'
}
]
},
{
name: 'accessControl',
path: '/access-control',
key: 'accessControl',
routes: [
{
path: '/access-control',
redirect: '/access-control/users'
},
{
name: 'organizations',
path: '/access-control/organizations',
key: 'organizations',
icon: 'icon-org-outlined',
selectedIcon: 'icon-org-filled',
defaultIcon: 'icon-org-outlined',
// OSS exposes the menu to platform admins as a teaser for the
// enterprise multi-tenancy module. The page itself just renders
// an upsell notice — the real CRUD UI lives in the enterprise
// plugin and shadows this route via `routes.extensions.ts`.
access: 'canSeeAdmin', access: 'canSeeAdmin',
component: './organizations' component: './resources'
},
{
name: 'users',
path: '/access-control/users',
key: 'users',
icon: 'icon-users',
selectedIcon: 'icon-users-filled',
defaultIcon: 'icon-users',
access: 'canSeeAdmin',
component: './users'
},
{
name: 'apikeys',
path: '/access-control/api-keys',
key: 'apikeys',
selectedIcon: 'icon-key-filled',
icon: 'icon-key',
defaultIcon: 'icon-key',
component: './api-keys'
}
]
}, },
{ {
name: 'apikeys', name: 'apikeys',
path: '/api-keys', path: '/api-keys',
key: 'apikeys', key: 'apikeys',
hideInMenu: true, icon: 'KeyOutlined',
selectedIcon: 'icon-key-filled',
icon: 'icon-key',
defaultIcon: 'icon-key',
component: './api-keys' component: './api-keys'
}, },
{
name: 'users',
path: '/users',
key: 'users',
icon: 'Team',
access: 'canSeeAdmin',
component: './users'
},
{ {
name: 'profile', name: 'profile',
path: '/preferences', path: '/profile',
key: 'preferences', key: 'profile',
hideInMenu: true, hideInMenu: true,
component: './profile', component: './profile',
icon: 'User' icon: 'User'
@@ -433,5 +124,3 @@ const baseRoutes = [
component: './404' component: './404'
} }
]; ];
export default applyRouteExtensions(baseRoutes);
+8 -22
View File
@@ -1,27 +1,13 @@
import { execSync } from 'child_process'; const child_process = require('child_process');
export const getBranchInfo = () => { export const getBranchInfo = () => {
// git may be absent (source archive, bare container) or this tree may const latestCommit = child_process
// not be a git checkout. Swallow the failure and fall back to the env .execSync('git rev-parse HEAD')
// overrides below — losing build info shouldn't fail the build.
let latestCommit = '';
let versionTag = '';
try {
latestCommit = execSync('git rev-parse HEAD').toString().trim();
versionTag = execSync(`git tag --contains ${latestCommit}`)
.toString() .toString()
.trim(); .trim();
} catch { const versionTag = child_process
// Not a git checkout / git unavailable; rely on env overrides. .execSync(`git tag --contains ${latestCommit}`)
} .toString()
// Respect explicit GPUSTACK_UI_* overrides so a wrapping build that .trim();
// checks this source tree out as a sub-package can stamp its own return { version: versionTag || '', commitId: latestCommit.slice(0, 7) };
// release tag and commit id onto the UI (otherwise the panel reports
// the host tree's git HEAD, which the wrapper doesn't control).
const overrideVersion = process.env.GPUSTACK_UI_VERSION?.trim();
const overrideCommitId = process.env.GPUSTACK_UI_COMMIT_ID?.trim();
return {
version: overrideVersion || versionTag || '',
commitId: overrideCommitId || latestCommit.slice(0, 7)
};
}; };
-79
View File
@@ -1,79 +0,0 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import importPlugin from 'eslint-plugin-import';
import reactPlugin from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import unusedImports from 'eslint-plugin-unused-imports';
import { defineConfig, globalIgnores } from 'eslint/config';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default defineConfig([
globalIgnores([
'public/static/',
'dist',
'src/.umi/',
'src/.umi-production/',
'src/.umi-test/',
'src/components/iconfont/'
]),
{
files: ['**/*.{ts,tsx,js,jsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
prettier
],
plugins: {
react: reactPlugin,
import: importPlugin,
'unused-imports': unusedImports
},
settings: {
react: {
version: 'detect'
},
'import/resolver': {
node: true,
typescript: true
}
},
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
...globals.node,
Global: 'readonly',
React: 'readonly',
JSX: 'readonly'
}
},
rules: {
'react/no-unstable-nested-components': 'warn',
'no-unused-vars': 'off',
'no-undef': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unnecessary-type-constraint': 'off',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': 'off',
'import/no-unresolved': 'off',
'import/no-duplicates': 'error',
'react-hooks/exhaustive-deps': 'off',
'react-hooks/preserve-manual-memoization': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/refs': 'off',
'react-hooks/use-memo': 'off',
'react-hooks/immutability': 'off',
'no-unsafe-optional-chaining': 'off',
'no-empty': 'off',
'no-constant-condition': 'off',
'no-prototype-builtins': 'off',
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0, maxBOF: 0 }]
}
}
]);
+21 -45
View File
@@ -1,9 +1,9 @@
{ {
"private": true, "private": true,
"author": "gpustack", "author": "jialin",
"scripts": { "scripts": {
"build": "max build", "build": "max build",
"check:locales": "node --import tsx ./src/locales/check.ts", "check:locales": "npx tsx ./src/locales/check.ts",
"dev": "max dev", "dev": "max dev",
"format": "prettier --cache --write .", "format": "prettier --cache --write .",
"postinstall": "max setup", "postinstall": "max setup",
@@ -12,12 +12,13 @@
"setup": "max setup", "setup": "max setup",
"start": "npm run dev" "start": "npm run dev"
}, },
"resolutions": {
"immer": "^9.0.6"
},
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^5.5.1",
"@ant-design/pro-components": "3.1.0-0", "@ant-design/pro-components": "^2.7.19",
"@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1", "@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.35",
"@huggingface/gguf": "^0.1.7", "@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1", "@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6", "@huggingface/tasks": "^0.11.6",
@@ -25,104 +26,79 @@
"@orcid/bibtex-parse-js": "^0.0.25", "@orcid/bibtex-parse-js": "^0.0.25",
"@react-hook/resize-observer": "^2.0.2", "@react-hook/resize-observer": "^2.0.2",
"@types/lodash": "^4.17.4", "@types/lodash": "^4.17.4",
"@umijs/max": "^4.6.15", "@umijs/max": "^4.2.11",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"ahooks": "^3.8.5", "ahooks": "^3.8.5",
"ansi-to-html": "^0.7.2", "ansi-to-html": "^0.7.2",
"antd": "^6.3.3", "antd": "^5.21.6",
"antd-style": "^3.6.2", "antd-style": "^3.6.2",
"axios": "^1.8.2", "axios": "^1.7.2",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"culori": "^4.0.2",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"dompurify": "^3.2.6", "driver.js": "^1.3.1",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"file-saver": "^2.0.5", "epubjs": "^0.3.93",
"has-ansi": "^5.0.1", "has-ansi": "^5.0.1",
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
"jdenticon": "^3.3.0",
"jotai": "^2.8.4", "jotai": "^2.8.4",
"js-yaml": "^4.1.0", "jszip": "^3.10.1",
"katex": "^0.16.21", "katex": "^0.16.21",
"lamejs": "github:zhuker/lamejs", "lamejs": "github:zhuker/lamejs",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mammoth": "^1.8.0",
"marked": "^14.1.0", "marked": "^14.1.0",
"minimatch": "^3.1.2",
"ml-dataset-iris": "^1.2.1", "ml-dataset-iris": "^1.2.1",
"ml-pca": "^4.1.1", "ml-pca": "^4.1.1",
"monaco-editor": "^0.30.1",
"monaco-yaml": "^4.0.0",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"overlayscrollbars": "^2.10.0", "overlayscrollbars": "^2.10.0",
"overlayscrollbars-react": "^0.5.6", "overlayscrollbars-react": "^0.5.6",
"pdfjs-dist": "^4.7.76",
"query-string": "^9.0.0", "query-string": "^9.0.0",
"rc-resize-observer": "^1.4.3", "rc-resize-observer": "^1.4.0",
"rc-virtual-list": "^3.14.8", "rc-virtual-list": "^3.14.8",
"re-resizable": "^6.10.1", "re-resizable": "^6.10.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.5.0", "react-hotkeys-hook": "^4.5.0",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^9.0.3", "react-markdown": "^9.0.3",
"react-router-dom": "^6.30.3",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"semver": "^7.7.3",
"simplebar-react": "^3.2.6", "simplebar-react": "^3.2.6",
"styled-components": "^6.1.15", "styled-components": "^6.1.15",
"umi-presets-pro": "^2.0.3", "umi-presets-pro": "^2.0.3",
"wavesurfer.js": "^7.8.8" "wavesurfer.js": "^7.8.8",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@types/marked": "^6.0.0",
"@types/node": "^25.0.3",
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/semver": "^7.7.1",
"@typescript-eslint/eslint-plugin": "^8.58.1",
"@umijs/case-sensitive-paths-webpack-plugin": "^1.0.1", "@umijs/case-sensitive-paths-webpack-plugin": "^1.0.1",
"@umijs/plugins": "^4.4.11",
"babel-plugin-named-asset-import": "^0.3.8", "babel-plugin-named-asset-import": "^0.3.8",
"case-sensitive-paths-webpack-plugin": "^2.4.0", "case-sensitive-paths-webpack-plugin": "^2.4.0",
"compression-webpack-plugin": "^11.1.0", "compression-webpack-plugin": "^11.1.0",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"eslint": "^9.39.4", "eslint": "^8.56.0",
"eslint-config-prettier": "^10.1.8", "eslint-plugin-unused-imports": "^3.2.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-unused-imports": "^4.4.1",
"extract-css-loader": "^0.0.1", "extract-css-loader": "^0.0.1",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"globals": "^17.4.0",
"husky": "^9.0.11", "husky": "^9.0.11",
"less-loader": "^12.2.0", "less-loader": "^12.2.0",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"mini-css-extract-plugin": "^2.9.0", "mini-css-extract-plugin": "^2.9.0",
"monaco-editor-webpack-plugin": "^4.2.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-packagejson": "^2.5.0", "prettier-plugin-packagejson": "^2.5.0",
"prettier-plugin-two-style-order": "^1.0.1", "prettier-plugin-two-style-order": "^1.0.1",
"tsx": "^4.19.3", "tsx": "^4.19.3",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"typescript-eslint": "^8.58.0",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack-bundle-analyzer": "^4.10.2", "webpack-bundle-analyzer": "^4.10.2",
"worker-loader": "^3.0.8" "worker-loader": "^3.0.8"
},
"packageManager": "pnpm@9.3.0",
"pnpm": {
"overrides": {
"elliptic": "^6.6.1"
}
} }
} }
-2
View File
@@ -5,8 +5,6 @@ export default (api: IApi) => {
const info = JSON.parse(process.env.VERSION || '{}'); const info = JSON.parse(process.env.VERSION || '{}');
const env = process.env.NODE_ENV; const env = process.env.NODE_ENV;
$('html').attr('lang', 'en');
$('html').attr('data-env', env); $('html').attr('data-env', env);
$('html').attr( $('html').attr(
+16210 -16142
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

-32
View File
@@ -1,32 +0,0 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const pnpmDir = path.resolve(__dirname, '../../../node_modules/.pnpm');
function findDir(prefix) {
return fs.readdirSync(pnpmDir).find((name) => name.startsWith(prefix));
}
const stylelintDir = findDir('stylelint@14.8.2');
if (!stylelintDir) {
console.error('Compatible stylelint@14.8.2 not found in workspace node_modules/.pnpm');
process.exit(1);
}
const stylelintBin = path.join(
pnpmDir,
stylelintDir,
'node_modules/stylelint/bin/stylelint.js'
);
const result = spawnSync(process.execPath, [stylelintBin, ...process.argv.slice(2)], {
stdio: 'inherit',
cwd: process.cwd(),
});
if (result.error) {
console.error(result.error);
}
process.exit(result.status ?? 1);
-14
View File
@@ -1,14 +0,0 @@
// Identity hook for build-time access-predicate extensions. Tooling may
// overwrite this file to widen predicates; the original is restored on
// cleanup. Mirrors `config/routes.extensions.ts`.
export type AccessPredicates = {
canSeeAdmin: boolean;
canSeeOrgAdmin: boolean;
canManageCurrentOrg: boolean;
canSeeUser: boolean;
canDelete: boolean;
canLogin: boolean;
};
export const applyAccessExtensions = <T extends AccessPredicates>(base: T): T =>
base;
+5 -49
View File
@@ -1,56 +1,12 @@
import { applyAccessExtensions } from './access.extensions'; export default (initialState: { currentUser?: Global.UserInfo }) => {
const canSeeAdmin = !!(
export default (initialState: {
currentUser?: Global.UserInfo;
hasKubernetesCluster?: boolean;
hasResourceEvents?: boolean;
}) => {
const isPlatformAdmin = !!(
initialState && initialState &&
initialState.currentUser && initialState.currentUser &&
initialState.currentUser.is_admin initialState.currentUser.is_admin
); );
const canSeeUser = !!( return {
initialState && canSeeAdmin,
initialState.currentUser &&
!initialState.currentUser.is_admin
);
// GPU Service is Kubernetes-only. We only gate visibility down when
// the probe in `getInitialState` came back with a definitive answer;
// `undefined` (probe failed / not yet ready) collapses to the
// role-based default so a transient network blip can't lock anyone
// out of the menu.
const hasKubernetesCluster = initialState?.hasKubernetesCluster;
// Having run GPU/CPU instances or storage (any resource_events) also unlocks
// GPU Service / the full Usage page — a user who used it keeps seeing it even
// without a current cluster. MaaS-only users (no cluster, no events) don't.
const hasResourceEvents = !!initialState?.hasResourceEvents;
// Predicate roles, top-down by strictness:
// * `canSeeAdmin` — strictly platform admin (`users.is_admin`).
// Gates Users.
// * `canSeeOrgAdmin` — admin-style menus that work cross-org
// (Dashboard, Resources, Models, Cluster Management). Defaults
// to platform admin; extensions widen to include org admins.
// * `canSeeGpuService` — GPU Service menu. Anyone allowed to
// manage clusters (admins, Org owners) sees it; non-admins fall
// through to "show only if a Kubernetes cluster is actually
// reachable" so Org members without scheduling access don't see
// a dead-end menu item.
// * `canManageCurrentOrg` — pages that only make sense inside a
// specific org context (member / group management). Defaults to
// `false`; extensions widen when both an org is selected AND
// the caller is admin of it.
// Pass through `applyAccessExtensions` so build-time tooling can
// widen these without editing this file. Default is a no-op.
return applyAccessExtensions({
canSeeAdmin: isPlatformAdmin,
canSeeOrgAdmin: isPlatformAdmin,
canSeeGpuService:
isPlatformAdmin || hasKubernetesCluster !== false || hasResourceEvents,
canManageCurrentOrg: false,
canSeeUser,
canDelete: true, canDelete: true,
canLogin: true canLogin: true
}); };
}; };
+14 -111
View File
@@ -1,34 +1,20 @@
import { userSettingsHelperAtom } from '@/atoms/settings'; import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user';
import { GPUStackVersionAtom, UpdateCheckAtom, userAtom } from '@/atoms/user';
import { setAtomStorage } from '@/atoms/utils'; import { setAtomStorage } from '@/atoms/utils';
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
import { COLOR_PRIMARY } from '@/config/theme/constants';
import { getGPUStackPlugin } from '@/plugins';
import { enterprisePluginReady } from '@/plugins/enterprise-ready';
import { GPUStackPluginManager } from '@/plugins/manager';
import { requestConfig } from '@/request-config'; import { requestConfig } from '@/request-config';
import { import {
queryCurrentUserState, queryCurrentUserState,
queryVersionInfo, queryVersionInfo,
updateCheck updateCheck
} from '@/services/profile/apis'; } from '@/services/profile/apis';
import { fetchSystemConfig } from '@/services/system/query-system-config';
import { isOnline } from '@/utils'; import { isOnline } from '@/utils';
import {
markInitialStateProbed,
probeAccessFlags
} from '@/utils/access-probes';
import { installTenantFetch } from '@/utils/install-fetch';
import { import {
IS_FIRST_LOGIN, IS_FIRST_LOGIN,
readState, readState,
writeState writeState
} from '@/utils/localstore/index'; } from '@/utils/localstore/index';
import '@gpustack/core-ui/style.css'; import { RequestConfig, history } from '@umijs/max';
import { RequestConfig, history, request as umiRequest } from '@umijs/max';
import { message } from 'antd';
installTenantFetch(); const loginPath = '/login';
// only for the first login and access from http://localhost // only for the first login and access from http://localhost
@@ -37,7 +23,7 @@ const checkDefaultPage = async (userInfo: any) => {
if (isFirstLogin === null && isOnline()) { if (isFirstLogin === null && isOnline()) {
writeState(IS_FIRST_LOGIN, true); writeState(IS_FIRST_LOGIN, true);
if (userInfo && userInfo?.is_admin) { if (userInfo && userInfo?.is_admin) {
history.push(DEFAULT_ENTER_PAGE.adminForFirst); history.push('/models/list');
} }
} }
}; };
@@ -46,29 +32,9 @@ const checkDefaultPage = async (userInfo: any) => {
export async function getInitialState(): Promise<{ export async function getInitialState(): Promise<{
fetchUserInfo: () => Promise<Global.UserInfo>; fetchUserInfo: () => Promise<Global.UserInfo>;
currentUser?: Global.UserInfo; currentUser?: Global.UserInfo;
pluginData?: Record<string, any>;
hasKubernetesCluster?: boolean;
hasResourceEvents?: boolean;
}> { }> {
const { location } = history; const { location } = history;
// In open-source builds the promise resolves immediately.
await enterprisePluginReady;
// initialize plugins and merge enterprise locales
let pluginData = {};
try {
pluginData = await GPUStackPluginManager.initialize({
request: umiRequest,
setUserSettings: (value) => setAtomStorage(userSettingsHelperAtom, value),
setStorageUserSettings: (value) =>
setAtomStorage(userSettingsHelperAtom, value),
defaultColorPrimary: COLOR_PRIMARY
});
} catch (error) {
console.error('Failed to initialize plugins:', error);
}
const getUpdateCheck = async () => { const getUpdateCheck = async () => {
try { try {
const data = await updateCheck(); const data = await updateCheck();
@@ -82,61 +48,17 @@ export async function getInitialState(): Promise<{
} }
}; };
const fetchUserInfo = async (config?: { const fetchUserInfo = async (): Promise<Global.UserInfo> => {
skipErrorHandler?: boolean;
}): Promise<Global.UserInfo> => {
try { try {
const data = await queryCurrentUserState({ const data = await queryCurrentUserState({
skipErrorHandler: true skipErrorHandler: true
}); });
if (data.is_admin) { if (data.is_admin) {
getUpdateCheck(); getUpdateCheck();
fetchSystemConfig();
}
// Only commit a substantive user object. A truthy-but-empty
// `data` (e.g. server responded 200 with an empty body) would
// otherwise look like "logged in" to every `currentUser`
// reader and the access seam — break out instead and let the
// caller treat the request as failed.
if (data && typeof data === 'object' && Object.keys(data).length > 0) {
// Commit the identity to atom storage (and so to localStorage)
// before returning. The access function — memoized on
// `initialState` and run once per commit — reads identity from
// localStorage; without this preemptive write the predicate
// sees the prior session's identity on its first evaluation
// after login, and stays stale until the next identity change
// (which usually doesn't come without a manual refresh).
try {
setAtomStorage(userAtom, data);
} catch (err) {
console.error('userAtom commit error:', err);
}
// Fire `onUserFetched` so plugins maintaining identity-scoped
// caches can seed them under the new identity before any
// caller commits this user to `initialState`. Errors here are
// swallowed and logged — fetchUserInfo must still return.
try {
await getGPUStackPlugin()?.login?.onUserFetched?.(data, {
request: umiRequest
});
} catch (err) {
console.error('onUserFetched plugin hook error:', err);
}
} }
return data; return data;
} catch (error: any) { } catch (error) {
const data = error?.response?.data; history.push(loginPath);
if (data?.code === 401 && data?.message.includes('deactivate')) {
message.error({
content: (
<div>
<span>{data?.message}</span>
</div>
),
duration: 5
});
}
history.push(DEFAULT_ENTER_PAGE.login);
} }
return {} as Global.UserInfo; return {} as Global.UserInfo;
}; };
@@ -144,15 +66,10 @@ export async function getInitialState(): Promise<{
const getAppVersionInfo = async () => { const getAppVersionInfo = async () => {
try { try {
const data = await queryVersionInfo(); const data = await queryVersionInfo();
const isProduction = data.version?.indexOf('0.0.0') === -1;
const isDev = data.version?.indexOf('0.0.0') > -1;
const isRc = data.version?.indexOf('rc') > -1;
setAtomStorage(GPUStackVersionAtom, { setAtomStorage(GPUStackVersionAtom, {
...data, ...data,
isProd: !isDev && !isRc, isProduction
isDev,
isRc
}); });
} catch (error) { } catch (error) {
console.error('queryVersionInfo error', error); console.error('queryVersionInfo error', error);
@@ -161,34 +78,20 @@ export async function getInitialState(): Promise<{
getAppVersionInfo(); getAppVersionInfo();
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) { if (![loginPath].includes(location.pathname)) {
const [userInfo, accessFlags] = await Promise.all([ const userInfo = await fetchUserInfo();
fetchUserInfo(),
probeAccessFlags()
]);
// Record that the probes ran for an authenticated user this page load
// (the refresh path) so the layout doesn't re-probe. A failed
// fetch (empty user — e.g. unauthenticated deep link that bounces to
// login) is NOT marked: the user will log in via SPA afterwards and
// the layout becomes responsible for probing.
if (userInfo?.username) {
markInitialStateProbed();
}
checkDefaultPage(userInfo); checkDefaultPage(userInfo);
return { return {
fetchUserInfo, fetchUserInfo,
currentUser: userInfo, currentUser: userInfo
pluginData,
...accessFlags
}; };
} }
return { return {
fetchUserInfo, fetchUserInfo
pluginData
}; };
} }
export const request: RequestConfig = { export const request: RequestConfig = {
baseURL: `/${GPUSTACK_API_BASE_URL}`, baseURL: ' /v1',
...requestConfig ...requestConfig
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 14 KiB

-1
View File
@@ -1 +0,0 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1755020187776" class="icon" viewBox="0 0 1105 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7392" xmlns:xlink="http://www.w3.org/1999/xlink" width="138.125" height="128"><path d="M507.2896 133.44768l130.98496-71.4496v820.9408l-130.97984 69.71392V133.44768z" fill="#F38019" p-id="7393"></path><path d="M479.39072 323.968a724.30592 724.30592 0 0 0-77.056 16.6144C118.35904 417.01376 37.77536 603.8016 67.95776 701.696c30.11584 97.8944 161.03424 263.2192 439.2704 250.19904 0.13824-27.41248 0.13824-57.73824 0-90.89536-200.49408-26.37824-302.67904-88.82688-306.55488-187.40736-5.74464-147.74272 134.1696-216.90368 217.87136-229.0176 16.82432-2.49344 37.66272-5.67808 61.19936-8.6528l-0.27648-111.9488h-0.07168z m185.12384 107.65312c52.1984 5.6832 103.57248 18.69312 147.0464 44.44672-10.65984 5.19168-34.26816 19.73248-70.82496 43.47904l290.49344 66.39104-16.54272-206.58176-78.37184 44.30336c-116.10112-57.8048-205.19936-91.0336-267.29984-99.6864l-4.50048 107.648z" fill="#ADAFB3" p-id="7394"></path></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M140-180v-88.92q0-29.39 15.96-54.43 15.96-25.03 42.66-38.49 59.3-29.08 119.65-43.62Q378.62-420 440-420q21.06 0 42.12 1.89 21.05 1.88 42.11 5.65-1.69 51.46 20.81 96.81Q567.54-270.31 609-240v60H140ZM753.85-72.31l-53.35-53.14v-164.73q-39.11-11.51-63.85-43.97-24.73-32.46-24.73-74.7 0-51.46 36.38-87.84t87.85-36.38q51.46 0 87.66 36.39Q860-460.28 860-408.79q0 39.94-22.42 70.71-22.43 30.77-57.2 44.23l44.23 44.23-53.07 53.2 53.07 53.19-70.76 70.92ZM440-484.62q-57.75 0-98.87-41.12Q300-566.86 300-624.61q0-57.75 41.13-98.88 41.12-41.12 98.87-41.12 57.75 0 98.87 41.12Q580-682.36 580-624.61q0 57.75-41.13 98.87-41.12 41.12-98.87 41.12Zm296.15 93.47q14.7 0 25.04-10.54 10.35-10.54 10.35-25.23 0-14.69-10.35-25.04-10.34-10.35-25.04-10.35-14.69 0-25.23 10.35-10.54 10.35-10.54 25.04t10.54 25.23q10.54 10.54 25.23 10.54Z"/></svg>

Before

Width:  |  Height:  |  Size: 934 B

-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<g fill="#C22E33">
<path d="M7.754 2l.463.41c.343.304.687.607 1.026.915C11.44 5.32 13.3 7.565 14.7 10.149c.072.132.137.268.202.403l.098.203-.108.057-.081-.115-.21-.299-.147-.214c-1.019-1.479-2.04-2.96-3.442-4.145a6.563 6.563 0 00-1.393-.904c-1.014-.485-1.916-.291-2.69.505-.736.757-1.118 1.697-1.463 2.653-.045.123-.092.245-.139.367l-.082.215-.172-.055c.1-.348.192-.698.284-1.049.21-.795.42-1.59.712-2.356.31-.816.702-1.603 1.093-2.39.169-.341.338-.682.5-1.025h.092z"/>
<path d="M8.448 11.822c-1.626.77-5.56 1.564-7.426 1.36C.717 11.576 3.71 4.05 5.18 2.91l-.095.218a4.638 4.638 0 01-.138.303l-.066.129c-.76 1.462-1.519 2.926-1.908 4.53a7.482 7.482 0 00-.228 1.689c-.01 1.34.824 2.252 2.217 2.309.67.027 1.347-.043 2.023-.114.294-.03.587-.061.88-.084.108-.008.214-.021.352-.039l.231-.028z"/>
<path d="M3.825 14.781c-.445.034-.89.068-1.333.108 4.097.39 8.03-.277 11.91-1.644-1.265-2.23-2.97-3.991-4.952-5.522.026.098.084.169.141.239l.048.06c.17.226.348.448.527.67.409.509.818 1.018 1.126 1.578.778 1.42.356 2.648-1.168 3.296-1.002.427-2.097.718-3.18.892-1.03.164-2.075.243-3.119.323z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 286 64"><g><g><defs><path id="SVGID_1_" d="M47.5 17.6L25 4.8v52.6l9-5.2V37.4l6.8 3.9-.1-10.1-6.7-3.9v-5.9l13.5 7.9z"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" overflow="visible"/></clipPath><g clip-path="url(#SVGID_2_)"><linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-1.6" y1="335.05" x2="53.6" y2="335.05" gradientTransform="translate(0 -304)"><stop offset="0" stop-color="#ff6f00"/><stop offset="1" stop-color="#ffa800"/></linearGradient><path d="M-1.6 4.6h55.2v52.9H-1.6V4.6z" fill="url(#SVGID_3_)"/></g></g></g><g><g><defs><path id="SVGID_4_" d="M.5 17.6L23 4.8v52.6l-9-5.2V21.4L.5 29.3z"/></defs><clipPath id="SVGID_5_"><use xlink:href="#SVGID_4_" overflow="visible"/></clipPath><g clip-path="url(#SVGID_5_)"><linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="-1.9" y1="335.05" x2="53.3" y2="335.05" gradientTransform="translate(0 -304)"><stop offset="0" stop-color="#ff6f00"/><stop offset="1" stop-color="#ffa800"/></linearGradient><path d="M-1.9 4.6h55.2v52.9H-1.9V4.6z" fill="url(#SVGID_6_)"/></g></g></g><path style="fill:#425066" d="M88.2 21.1h-10v27.7h-5.6V21.1h-10v-4.5h25.6v4.5z"/><path style="fill:#425066" d="M94.9 49.2c-3.4 0-6.2-1.1-8.3-3.2-2.1-2.1-3.2-5-3.2-8.6v-.7c0-2.2.4-4.4 1.4-6.4.9-1.8 2.2-3.3 3.9-4.4 1.7-1.1 3.6-1.6 5.6-1.6 3.3 0 5.8 1 7.6 3.1s2.7 5 2.7 8.8v2.2H88.9c.1 1.8.8 3.4 2 4.7 1.2 1.2 2.7 1.8 4.4 1.7 2.4.1 4.6-1.1 6-3l2.9 2.8c-1 1.4-2.3 2.6-3.8 3.3-1.8 1-3.6 1.4-5.5 1.3zm-.6-20.5c-1.4-.1-2.7.5-3.6 1.5-1 1.2-1.6 2.7-1.7 4.3h10.3v-.4c-.1-1.8-.6-3.2-1.4-4.1-1-.8-2.3-1.4-3.6-1.3zm19.4-3.9l.2 2.8c1.7-2.1 4.3-3.3 7-3.2 5 0 7.5 2.9 7.6 8.6v15.8h-5.4V33.3c0-1.5-.3-2.6-1-3.4-.7-.7-1.7-1.1-3.2-1.1-2.1-.1-4 1.1-4.9 2.9v17h-5.4v-24l5.1.1zm32.2 17.5c0-.9-.4-1.7-1.2-2.2-1.2-.7-2.6-1.1-3.9-1.3-1.6-.3-3.1-.8-4.6-1.5-2.7-1.3-4-3.2-4-5.6 0-2 1-4 2.6-5.2 1.7-1.4 4-2.1 6.6-2.1 2.9 0 5.2.7 6.9 2.1 1.7 1.3 2.7 3.4 2.6 5.5h-5.4c0-1-.4-1.9-1.2-2.6-.9-.7-1.9-1.1-3.1-1-1 0-2 .2-2.9.8-.7.5-1.1 1.3-1.1 2.2 0 .8.4 1.5 1 1.9.7.5 2.1.9 4.2 1.4 1.7.3 3.4.9 5 1.7 1.1.5 2 1.3 2.7 2.3.6 1 .9 2.1.9 3.3 0 2.1-1 4-2.7 5.2-1.8 1.3-4.1 2-7 2-1.8 0-3.6-.3-5.2-1.1-1.4-.6-2.7-1.6-3.6-2.9-.8-1.2-1.3-2.6-1.3-4h5.2c0 1.1.5 2.2 1.4 2.9 1 .7 2.3 1.1 3.5 1 1.4 0 2.5-.3 3.2-.8 1-.4 1.4-1.2 1.4-2zm8.1-5.7c0-2.2.4-4.4 1.4-6.3.9-1.8 2.2-3.3 3.9-4.3 1.8-1 3.8-1.6 5.8-1.5 3.2 0 5.9 1 7.9 3.1s3.1 4.8 3.3 8.3v1.3c0 2.2-.4 4.3-1.4 6.3-.8 1.8-2.2 3.3-3.9 4.3-1.8 1-3.8 1.6-5.9 1.5-3.4 0-6.1-1.1-8.1-3.4-2-2.2-3-5.2-3.1-9l.1-.3zm5.3.5c0 2.5.5 4.4 1.5 5.8 1.8 2.3 5.1 2.8 7.5 1 .4-.3.7-.6 1-1 1-1.4 1.5-3.5 1.5-6.2 0-2.4-.5-4.3-1.6-5.8-1.7-2.3-5-2.8-7.4-1.1-.4.3-.8.7-1.1 1-.8 1.4-1.4 3.5-1.4 6.3zm33.1-7.3c-.7-.1-1.5-.2-2.2-.2-2.5 0-4.1.9-5 2.8v16.4h-5.4v-24h5.1l.1 2.7c1.3-2.1 3.1-3.1 5.4-3.1.6 0 1.3.1 1.9.3l.1 5.1zm22.5 5.3h-13v13.7h-5.6V16.6h20.5v4.5h-14.9v9.6h13v4.4zm10.7 13.7h-5.4V16.5h5.4v32.3zm3.9-12.2c0-2.2.4-4.4 1.4-6.3.9-1.8 2.2-3.3 3.9-4.3 1.8-1 3.8-1.6 5.8-1.5 3.2 0 5.9 1 7.9 3.1s3.1 4.8 3.3 8.3v1.3c0 2.2-.4 4.3-1.3 6.3-.8 1.8-2.2 3.3-3.9 4.3-1.8 1-3.8 1.6-5.9 1.5-3.4 0-6.1-1.1-8.1-3.4-2-2.2-3.1-5.2-3.1-9v-.3zm5.4.5c0 2.5.5 4.4 1.5 5.8 1 1.4 2.6 2.2 4.3 2.1 1.7.1 3.3-.7 4.2-2.1 1-1.4 1.5-3.5 1.5-6.2 0-2.4-.5-4.3-1.6-5.8-1.7-2.3-5-2.8-7.4-1.1-.4.3-.8.7-1.1 1-.9 1.4-1.4 3.5-1.4 6.3zm41.2 4.3l3.8-16.5h5.2l-6.5 24h-4.4l-5.1-16.5-5.1 16.5h-4.4l-6.6-24h5.3l3.9 16.4 4.9-16.4h4.1l4.9 16.5z"/></svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 801 B

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