Compare commits

..
19 Commits
Author SHA1 Message Date
jialinandjialin 29b965ebbd style(cluster): center pool replicas cell
CI / deps (push) Has been cancelled
CI / build (push) Has been cancelled
CI / build-publish (push) Has been cancelled
CI / trigger-backend (push) Has been cancelled
2026-07-23 20:49:23 +08:00
jialinandjialin aca6490da1 fix(llmodels): stale instances lingering after missed DELETE watch events 2026-07-23 15:53:21 +08:00
jialinandjialin 04511a63a3 fix(models): cluster auto-selection mismatch when org scope settles late 2026-07-23 15:33:42 +08:00
jialinandjialin 28da5d52e8 style(cluster): col width 2026-07-22 16:29:04 +08:00
jialinandjialin 730cbc9b64 style(cluster): cell width 2026-07-22 15:50:32 +08:00
hibigandjialin 2a69ba65cb chore: bump @gpustack/core-ui to v1.0.42 2026-07-22 15:41:25 +08:00
jialinandjialin aefb8eadce chore(theme): add Card headerHeight token 2026-07-22 10:56:06 +08:00
jialinandjialin 49c80b2ccb feat(llmodels): use CardRadioGroup for access scope selection 2026-07-22 10:56:06 +08:00
hibigandjialin 654eca7544 chore: bump @gpustack/core-ui to v1.0.41 2026-07-22 10:39:55 +08:00
Yuxing Dengandjialin 5ee62f267c feat(billing): on-card charge/pay badge slots for models & instance types
Add the plugin-fed billing price-badge slots to the model cards and the
gpu-instance-type / storage cards so the enterprise plugin can surface
charge / pay indicators inline.
2026-07-22 09:46:11 +08:00
jialinandjialin ba0b2bb2c3 feat(maas-provider): allow clearing the model AutoComplete 2026-07-20 20:24:28 +08:00
jialinandjialin c3bdbc200e style(backends): elevate community card and version title backgrounds 2026-07-20 20:24:28 +08:00
jialinandjialin 63b06e595e feat(access-control): render principals Add button in host holder slot 2026-07-20 20:24:28 +08:00
jialinandjialin 27210da13a style(alert): tighten with-description padding, icon size, and title line-height 2026-07-20 20:24:28 +08:00
jialinandjialin 831a760246 style: adjust table row bg/height, container color, auto table layout 2026-07-20 15:55:39 +08:00
jialinandjialin ae06ad7aae feat(workers): add tooltip hint on VRAM index, merge index+bar trigger
Blue dashed underline on the VRAM GPU index; index and ProgressBar share one Tooltip trigger showing the memory detail.
2026-07-20 15:55:39 +08:00
jialinandjialin b3b819a080 refactor: rename gpu-usage-cell to dual-arc-gauge, refine colors
Fixed blue for allocated (var(--ant-blue-6), theme-independent), gray dot when allocated is 0, dashed underline interaction hint.
2026-07-20 15:55:39 +08:00
jialinandjialin aa47c626ba feat: add GpuUsageCell component
Semicircle dual-arc gauge (used/allocated) with hover Popover for GPU memory usage in table cells.
2026-07-20 15:55:39 +08:00
hibigandjialin 79e4a60d52 chore: bump @gpustack/core-ui to v1.0.40 2026-07-20 14:39:18 +08:00
26 changed files with 529 additions and 136 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"@ant-design/pro-components": "3.1.0-0", "@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51", "@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1", "@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.39", "@gpustack/core-ui": "^1.0.42",
"@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",
+5 -5
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1 specifier: ^7.1.1
version: 7.1.2 version: 7.1.2
'@gpustack/core-ui': '@gpustack/core-ui':
specifier: ^1.0.39 specifier: ^1.0.42
version: 1.0.39(czdvzceysqw7iv6pct2ucnb23e) version: 1.0.42(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf': '@huggingface/gguf':
specifier: ^0.1.7 specifier: ^0.1.7
version: 0.1.18 version: 0.1.18
@@ -1481,8 +1481,8 @@ packages:
resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz} resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz}
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
'@gpustack/core-ui@1.0.39': '@gpustack/core-ui@1.0.42':
resolution: {integrity: sha512-6NS0TDkBFHK6RipfvLwvDnLZlAoa/yFbHV+xeERAnUb+4t0mDyIQFqvYAeDgrUW/JIZ6WScrm31FDZe2sKiuaw==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.39.tgz} resolution: {integrity: sha512-upMClTHU+xAqd8dlx0w1S9XWHlog5g1hcOCulTk2rmMXqgl66QHfqhEFhJnWIGe1xc8tEj6rW3r5Sirif+qswA==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.42.tgz}
peerDependencies: peerDependencies:
'@ant-design/icons': ^6.1.0 '@ant-design/icons': ^6.1.0
'@ant-design/pro-components': 3.1.0-0 '@ant-design/pro-components': 3.1.0-0
@@ -10802,7 +10802,7 @@ snapshots:
'@formatjs/intl-utils@2.3.0': {} '@formatjs/intl-utils@2.3.0': {}
'@gpustack/core-ui@1.0.39(czdvzceysqw7iv6pct2ucnb23e)': '@gpustack/core-ui@1.0.42(czdvzceysqw7iv6pct2ucnb23e)':
dependencies: dependencies:
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+3
View File
@@ -0,0 +1,3 @@
.ant-alert-with-description .ant-alert-title {
line-height: 1;
}
+1
View File
@@ -1,4 +1,5 @@
@import './table.less'; @import './table.less';
@import './alert.less';
.m-b-20 { .m-b-20 {
margin-bottom: 20px; margin-bottom: 20px;
+4
View File
@@ -43,6 +43,10 @@ export default {
DatePicker: { DatePicker: {
fontSizeLG: 14 fontSizeLG: 14
}, },
Alert: {
withDescriptionPadding: '12px 16px',
withDescriptionIconSize: 18
},
Menu: { Menu: {
iconSize: 16, iconSize: 16,
iconMarginInlineEnd: 12, iconMarginInlineEnd: 12,
+10 -3
View File
@@ -26,8 +26,8 @@ export default {
cellPaddingInline: 16, cellPaddingInline: 16,
cellPaddingBlock: 6, cellPaddingBlock: 6,
cellFontSize: 14, cellFontSize: 14,
rowSelectedHoverBg: 'rgb(249 249 249)', rowSelectedHoverBg: 'rgb(247 247 247)',
rowHoverBg: 'rgb(249 249 249)', rowHoverBg: 'rgb(247 247 247)',
rowSelectedBg: 'transparent', rowSelectedBg: 'transparent',
headerSortActiveBg: 'transparent', headerSortActiveBg: 'transparent',
headerSortHoverBg: 'transparent', headerSortHoverBg: 'transparent',
@@ -47,6 +47,13 @@ export default {
DatePicker: { DatePicker: {
fontSizeLG: 14 fontSizeLG: 14
}, },
Alert: {
withDescriptionPadding: '12px 16px',
withDescriptionIconSize: 18
},
Card: {
headerHeight: 50
},
Menu: { Menu: {
iconSize: 16, iconSize: 16,
iconMarginInlineEnd: 12, iconMarginInlineEnd: 12,
@@ -116,7 +123,7 @@ export default {
borderRadiusLG: 6, borderRadiusLG: 6,
borderRadius: 4, borderRadius: 4,
borderRadiusSM: 3, borderRadiusSM: 3,
colorBgContainer: '#fff', colorBgContainer: '#fdfdfd',
fontSize: 14, fontSize: 14,
motion: true motion: true
} }
+1 -15
View File
@@ -21,8 +21,6 @@ html {
--color-fill-sider: #f4f5f4; --color-fill-sider: #f4f5f4;
--color-bg-1: #f4f5f4; --color-bg-1: #f4f5f4;
--color-scroll-bg: #d9d9d9; --color-scroll-bg: #d9d9d9;
--color-fill-2: #fff;
--color-fill-3: #f3f6fa;
--color-logs-bg: #1e1e1e; --color-logs-bg: #1e1e1e;
--color-logs-text: #d4d4d4; --color-logs-text: #d4d4d4;
--layout-content-blockpadding: 24px; --layout-content-blockpadding: 24px;
@@ -76,7 +74,6 @@ html {
--ant-rate-star-color: #fadb14; --ant-rate-star-color: #fadb14;
--color-fill-spin-bg: rgba(255, 255, 255, 15%); --color-fill-spin-bg: rgba(255, 255, 255, 15%);
--width-tooltip-max: 420px; --width-tooltip-max: 420px;
--color-bg-tooltip: '#fff';
--color-modal-content-bg: rgba(255, 255, 255, 90%); --color-modal-content-bg: rgba(255, 255, 255, 90%);
--color-modal-box-shadow: 0 4px 16px rgba(0, 0, 0, 10%); --color-modal-box-shadow: 0 4px 16px rgba(0, 0, 0, 10%);
--color-spotlight-bg: rgba(255, 255, 255, 100%); --color-spotlight-bg: rgba(255, 255, 255, 100%);
@@ -101,7 +98,6 @@ html[data-theme='realDark'] {
--color-editor-dark: #00101f; --color-editor-dark: #00101f;
--color-editor-light: #fafafa; --color-editor-light: #fafafa;
--color-fill-spin-bg: rgba(55, 55, 55, 50%); --color-fill-spin-bg: rgba(55, 55, 55, 50%);
--color-bg-tooltip: #424242;
--color-editor-header-bg: #292929; --color-editor-header-bg: #292929;
--color-progress-text: rgba(255, 255, 255, 80%); --color-progress-text: rgba(255, 255, 255, 80%);
--color-modal-content-bg: #1f1f1f; --color-modal-content-bg: #1f1f1f;
@@ -116,19 +112,9 @@ html[data-theme='realDark'] {
.ant-result-image { .ant-result-image {
opacity: 0.9; opacity: 0.9;
} }
.ant-pro-page-container-affix .ant-pro-page-container-warp {
background-color: #292929 !important;
transition: none !important;
}
} }
html[data-theme='light'] { html[data-theme='light'] {
.ant-pro-page-container-affix .ant-pro-page-container-warp {
background-color: #fff !important;
transition: none !important;
}
background-color: #f4f5f6; background-color: #f4f5f6;
} }
@@ -211,7 +197,7 @@ body {
tr > td { tr > td {
border-bottom: none; border-bottom: none;
height: 68px; height: var(--seal-table-row-min-height);
} }
} }
+27 -13
View File
@@ -8,24 +8,38 @@ const findValidJSONStrings = (inputStr: string) => {
const openingBraceIndex = inputStr.indexOf('{', startIndex); const openingBraceIndex = inputStr.indexOf('{', startIndex);
if (openingBraceIndex === -1) break; // No more opening braces if (openingBraceIndex === -1) break; // No more opening braces
let closingBraceIndex = openingBraceIndex; // find the matching closing brace, ignoring braces inside string
// literals (e.g. a state_message containing `{`/`}`)
let closingBraceIndex = -1;
let braceCount = 0; let braceCount = 0;
let inString = false;
let escaped = false;
// find couple of braces for (let i = openingBraceIndex; i < inputStr.length; i++) {
while (closingBraceIndex < inputStr.length) { const char = inputStr[i];
if (inputStr[closingBraceIndex] === '{') { if (inString) {
braceCount++; if (escaped) {
} else if (inputStr[closingBraceIndex] === '}') { escaped = false;
braceCount--; } else if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = false;
} }
} else if (char === '"') {
inString = true;
} else if (char === '{') {
braceCount++;
} else if (char === '}') {
braceCount--;
if (braceCount === 0) { if (braceCount === 0) {
closingBraceIndex = i;
break; break;
} }
closingBraceIndex++; }
} }
if (braceCount !== 0) { if (closingBraceIndex === -1) {
// no matching closing brace // no matching closing brace yet, wait for more data
break; break;
} }
@@ -37,11 +51,11 @@ const findValidJSONStrings = (inputStr: string) => {
try { try {
const parsedData = JSON.parse(jsonString); const parsedData = JSON.parse(jsonString);
validJSONStrings.push(parsedData); validJSONStrings.push(parsedData);
startIndex = closingBraceIndex + 1;
} catch (error) { } catch (error) {
// mabye invalid JSON // skip the malformed segment instead of breaking, otherwise it jams
break; // the buffer and every later event on this stream is lost
} }
startIndex = closingBraceIndex + 1;
} }
return { return {
+4 -1
View File
@@ -43,8 +43,11 @@ export const createAxiosToken = (): CancelTokenSource => {
}; };
export const sliceData = (data: string, loaded: number, loadedSize: any) => { export const sliceData = (data: string, loaded: number, loadedSize: any) => {
// `loaded` is a byte count while `data` is a UTF-16 string; with any
// non-ASCII payload the two drift apart, so track consumed characters by
// string length only
const result = data.slice(loadedSize.current); const result = data.slice(loadedSize.current);
loadedSize.current = loaded; loadedSize.current = data.length;
return result; return result;
}; };
@@ -0,0 +1,208 @@
import { convertFileSize } from '@/utils';
import { useIntl } from '@umijs/max';
import { Flex, Popover, theme } from 'antd';
import { createStyles } from 'antd-style';
import type { GlobalToken } from 'antd/es/theme';
import React from 'react';
export interface DualArcGaugeItem {
index: number;
label?: string;
memory: {
total: number; // bytes
used: number; // bytes — outer arc (semantic color)
allocated: number; // bytes — inner arc (blue)
};
}
interface DualArcGaugeProps {
data: DualArcGaugeItem[];
}
// guage external radius: 20, internal radius: 16, stroke width: 2
const CX = 25;
const CY = 24;
const R_OUT = 20;
const R_IN = 16;
const SW = 2.5;
const L_OUT = Math.PI * R_OUT;
const L_IN = Math.PI * R_IN;
const arcPath = (r: number) =>
`M ${CX - r} ${CY} A ${r} ${r} 0 0 1 ${CX + r} ${CY}`;
const P_OUT = arcPath(R_OUT);
const P_IN = arcPath(R_IN);
const clamp01 = (n: number) => Math.min(Math.max(n, 0), 1);
// Used color: green < 75%, yellow < 90%, red >= 90%
const usedColor = (ratio: number, token: GlobalToken) => {
if (ratio >= 0.9) return token.colorError;
if (ratio >= 0.75) return token.colorWarning;
return token.colorSuccess;
};
const useStyles = createStyles(({ css, token }) => ({
row: css`
width: fit-content;
padding: 3px 6px;
border-radius: ${token.borderRadius}px;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: ${token.colorFillTertiary};
}
`,
index: css`
font-size: 12px;
font-weight: 500;
color: ${token.colorTextSecondary};
border-bottom: 1px dashed var(--ant-blue-6);
padding-bottom: 3px;
line-height: 1.2;
`,
popTitle: css`
font-size: 12px;
font-weight: 500;
color: ${token.colorText};
margin-bottom: 6px;
`,
popList: css`
font-size: 12px;
font-variant-numeric: tabular-nums;
`,
metric: css`
white-space: nowrap;
`,
dot: css`
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
`,
value: css`
min-width: 64px;
text-align: right;
font-weight: 500;
color: ${token.colorText};
`,
label: css`
color: ${token.colorTextTertiary};
`
}));
const DualArcGauge: React.FC<DualArcGaugeProps> = ({ data }) => {
const intl = useIntl();
const { styles } = useStyles();
const { token } = theme.useToken();
const trackColor = token.colorFillSecondary;
const allocColor = 'var(--ant-blue-6)';
return (
<Flex vertical gap={2} style={{ width: 'fit-content' }}>
{data.map((item) => {
const { total = 0, used = 0, allocated = 0 } = item.memory || {};
const usedR = total > 0 ? clamp01(used / total) : 0;
const allocR = total > 0 ? clamp01(allocated / total) : 0;
const pct = Math.round(usedR * 100);
const uc = usedColor(usedR, token);
// set dim color for allocated if used is 0, otherwise use the same color as used
const allocDotColor =
allocated > 0 ? allocColor : token.colorTextQuaternary;
const content = (
<div>
<div className={styles.popTitle}>
[{item.index}]{item.label ? ` ${item.label}` : ''} ·{' '}
{convertFileSize(total)}
</div>
<Flex vertical gap={4} className={styles.popList}>
<Flex align="center" gap={6} className={styles.metric}>
<span className={styles.dot} style={{ background: uc }} />
<span className={styles.value}>{convertFileSize(used)}</span>
<span className={styles.label}>
{intl.formatMessage({ id: 'resources.table.used' })}
</span>
</Flex>
<Flex align="center" gap={6} className={styles.metric}>
<span
className={styles.dot}
style={{ background: allocDotColor }}
/>
<span className={styles.value}>
{convertFileSize(allocated)}
</span>
<span className={styles.label}>
{intl.formatMessage({ id: 'resources.table.allocated' })}
</span>
</Flex>
</Flex>
</div>
);
return (
<Popover
key={item.index}
content={content}
placement="right"
trigger={['hover', 'click']}
>
<Flex align="center" gap={6} className={styles.row}>
<svg
viewBox="0 0 50 28"
width={54}
height={30}
style={{ overflow: 'visible', flexShrink: 0 }}
>
<path
d={P_OUT}
fill="none"
strokeWidth={SW}
stroke={trackColor}
/>
<path
d={P_IN}
fill="none"
strokeWidth={SW}
stroke={trackColor}
/>
{usedR > 0 && (
<path
d={P_OUT}
fill="none"
strokeWidth={SW}
stroke={uc}
strokeDasharray={`${(usedR * L_OUT).toFixed(1)} ${L_OUT.toFixed(1)}`}
/>
)}
{allocR > 0 && (
<path
d={P_IN}
fill="none"
strokeWidth={SW}
style={{ stroke: allocColor }}
strokeDasharray={`${(allocR * L_IN).toFixed(1)} ${L_IN.toFixed(1)}`}
/>
)}
<text
x={CX}
y={CY - 0.5}
textAnchor="middle"
fontSize={9.5}
fontWeight={500}
fill={uc}
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{pct}%
</text>
</svg>
{<span className={styles.index}>[{item.index}]</span>}
</Flex>
</Popover>
);
})}
</Flex>
);
};
export default DualArcGauge;
@@ -120,6 +120,11 @@ const CommunityBackends: React.FC<{
const renderItem = (item: ListItem) => { const renderItem = (item: ListItem) => {
return ( return (
<div
style={{
backgroundColor: 'var(--ant-color-bg-container)'
}}
>
<BackendCard <BackendCard
active={currentData?.id === item.id} active={currentData?.id === item.id}
data={item} data={item}
@@ -127,6 +132,7 @@ const CommunityBackends: React.FC<{
onClick={handleOnClickItem} onClick={handleOnClickItem}
actionsRenderer={actionsRenderer} actionsRenderer={actionsRenderer}
/> />
</div>
); );
}; };
+1 -1
View File
@@ -45,7 +45,7 @@ const Title = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: var(--ant-color-bg-container); background-color: var(--ant-color-bg-elevated);
font-weight: 600; font-weight: 600;
padding-top: 8px; padding-top: 8px;
padding-bottom: 8px; padding-bottom: 8px;
@@ -163,7 +163,11 @@ const PoolRows: React.FC<PoolRowsProps> = ({
key={col.dataIndex || col.key} key={col.dataIndex || col.key}
span={spanFor(col.dataIndex)} span={spanFor(col.dataIndex)}
style={{ style={{
color: 'var(--ant-color-text-secondary)' color: 'var(--ant-color-text-secondary)',
// CellContent shrinks to its content inside the flex
// cell, so its own align class can't center it —
// center at the cell level instead.
justifyContent: col.align
}} }}
> >
<CellContent <CellContent
@@ -153,7 +153,7 @@ const useClusterColumns = (
span: 3, span: 3,
render: (text: string, record: ClusterListItem) => ( render: (text: string, record: ClusterListItem) => (
<> <>
<AutoTooltip ghost title={text}> <AutoTooltip ghost title={text} minWidth={20}>
<span className="text-primary">{record.name}</span> <span className="text-primary">{record.name}</span>
</AutoTooltip> </AutoTooltip>
{record.is_default && ( {record.is_default && (
@@ -176,6 +176,7 @@ const useClusterColumns = (
dataIndex: 'provider', dataIndex: 'provider',
sorter: tableSorter(2), sorter: tableSorter(2),
span: spans.provider, span: spans.provider,
minWidth: 110,
render: (value: string) => ( render: (value: string) => (
<AutoTooltip ghost minWidth={20}> <AutoTooltip ghost minWidth={20}>
{ProviderLabelMap[value]} {ProviderLabelMap[value]}
@@ -185,7 +186,7 @@ const useClusterColumns = (
{ {
title: intl.formatMessage({ id: 'dashboard.totalgpus' }), title: intl.formatMessage({ id: 'dashboard.totalgpus' }),
dataIndex: 'gpus', dataIndex: 'gpus',
span: 2, width: 100,
sorter: tableSorter(3), sorter: tableSorter(3),
render: (value: number) => <span>{value}</span> render: (value: number) => <span>{value}</span>
}, },
@@ -194,13 +195,15 @@ const useClusterColumns = (
dataIndex: 'models', dataIndex: 'models',
sorter: tableSorter(4), sorter: tableSorter(4),
span: spans.deployments, span: spans.deployments,
maxWidth: 150,
render: (value: number) => <span>{value}</span> render: (value: number) => <span>{value}</span>
}, },
{ {
title: intl.formatMessage({ id: 'resources.nodes' }), title: intl.formatMessage({ id: 'resources.nodes' }),
dataIndex: 'workers', dataIndex: 'workers',
minWidth: 100,
maxWidth: 120,
sorter: tableSorter(5), sorter: tableSorter(5),
span: spans.workers,
render: (value: number, record: ClusterListItem) => ( render: (value: number, record: ClusterListItem) => (
<span> <span>
{record.ready_workers} / {record.workers} {record.ready_workers} / {record.workers}
@@ -211,6 +214,8 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'common.table.status' }), title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: 'state', dataIndex: 'state',
span: spans.status, span: spans.status,
minWidth: 80,
align: 'center',
render: (value: number, record: ClusterListItem) => ( render: (value: number, record: ClusterListItem) => (
<StatusTag <StatusTag
statusValue={{ statusValue={{
@@ -116,6 +116,7 @@ const usePoolsColumns = (
dataIndex: 'replicas', dataIndex: 'replicas',
span: 6, span: 6,
key: 'replicas', key: 'replicas',
align: 'center',
editable: { editable: {
valueType: 'number', valueType: 'number',
title: intl.formatMessage({ id: 'models.table.replicas.edit' }) title: intl.formatMessage({ id: 'models.table.replicas.edit' })
@@ -1,3 +1,4 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui'; import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Flex, Tag } from 'antd'; import { Flex, Tag } from 'antd';
@@ -271,6 +272,10 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
{cpuManufacturer} {cpuManufacturer}
</ThemeTag> </ThemeTag>
)} )}
<PluginExtraFields
name="InstanceTypeBillingBadge"
context={{ instanceType: item }}
/>
</Flex> </Flex>
</Title> </Title>
<InstanceMetadataSection spec={specData}></InstanceMetadataSection> <InstanceMetadataSection spec={specData}></InstanceMetadataSection>
@@ -1,3 +1,4 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton'; import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton';
import { TemplateCard } from '@gpustack/core-ui'; import { TemplateCard } from '@gpustack/core-ui';
import { Empty, Flex, Spin } from 'antd'; import { Empty, Flex, Spin } from 'antd';
@@ -40,6 +41,10 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
return ( return (
<Flex orientation="vertical" gap={16}> <Flex orientation="vertical" gap={16}>
<PluginExtraFields
name="InstanceTypeBillingProvider"
context={{ instanceTypes: dataList }}
/>
{dataList.map((item) => { {dataList.map((item) => {
const name = item.name; const name = item.name;
return ( return (
@@ -6,7 +6,7 @@ import { getGPUStackPlugin } from '@/plugins';
import { DownOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { DownOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { import {
AlertBlockInfo, AlertBlockInfo,
TooltipList, CardRadioGroup,
Transfer as TransferInner Transfer as TransferInner
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
@@ -15,9 +15,8 @@ import {
Dropdown, Dropdown,
DropdownProps, DropdownProps,
Empty, Empty,
Flex,
Form, Form,
Radio,
RadioChangeEvent,
Tooltip Tooltip
} from 'antd'; } from 'antd';
import { import {
@@ -80,9 +79,9 @@ const Label = styled.div`
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-weight: 500; font-weight: 500;
margin-block: 8px 12px; margin-bottom: 16px;
font-size: 14px; font-size: 13px;
color: var(--ant-color-text-tertiary); color: var(--ant-color-text-secondary);
`; `;
interface AccessControlFormProps { interface AccessControlFormProps {
@@ -107,6 +106,14 @@ type AllowedUsersOverride = {
routeId?: number; routeId?: number;
action: PageActionType; action: PageActionType;
}>; }>;
// Optional trigger (e.g. an "Add" button) rendered by the host in its
// own layout slot while `Field` renders the body. Same props as Field
// so the plugin can gate visibility on action/routeId.
Action?: React.ComponentType<{
form: any;
routeId?: number;
action: PageActionType;
}>;
}; };
// Extra policy entries prepended to the radio list (rendered before // Extra policy entries prepended to the radio list (rendered before
@@ -223,9 +230,7 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
} }
}; };
const handleOnPolicyChange = async (e: RadioChangeEvent) => { const handleOnPolicyChange = async (policy: string) => {
console.log('policy changed:', e.target.value);
const policy = e.target.value;
if (policy === ALLOWED_PRINCIPALS_POLICY) { if (policy === ALLOWED_PRINCIPALS_POLICY) {
form.setFieldsValue({ users: formDataCacheRef.current?.users || [] }); form.setFieldsValue({ users: formDataCacheRef.current?.users || [] });
} else { } else {
@@ -391,33 +396,29 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
: undefined : undefined
}} }}
> >
<Label> <Label>{intl.formatMessage({ id: 'models.table.accessScope' })}</Label>
{intl.formatMessage({ id: 'models.table.accessScope' })}
<Tooltip
title={
<TooltipList
list={buildAccessScopeTips(
allowedUsersOverride,
prependedPolicies
)}
></TooltipList>
}
>
<QuestionCircleOutlined />
</Tooltip>
</Label>
<Form.Item<AccessControlFormData> name="access_policy" noStyle> <Form.Item<AccessControlFormData>
<Radio.Group name="access_policy"
style={{
marginBottom: 16
}}
>
<CardRadioGroup
onChange={handleOnPolicyChange} onChange={handleOnPolicyChange}
style={{ marginBottom: 12 }}
options={[ options={[
...prependedPolicies.map((p) => ({ ...prependedPolicies.map((p) => ({
label: intl.formatMessage({ id: p.labelId }), label: intl.formatMessage({ id: p.labelId }),
description: intl.formatMessage({ id: p.tipsId ?? p.labelId }),
value: p.policyValue value: p.policyValue
})), })),
{ {
label: intl.formatMessage({ id: 'models.accessSettings.authed' }), label: intl.formatMessage({
id: 'models.accessSettings.authed'
}),
description: intl.formatMessage({
id: 'models.accessSettings.authed.tips'
}),
value: 'authed' value: 'authed'
}, },
allowedUsersOverride allowedUsersOverride
@@ -425,23 +426,52 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
label: intl.formatMessage({ label: intl.formatMessage({
id: allowedUsersOverride.labelId id: allowedUsersOverride.labelId
}), }),
description: intl.formatMessage({
id:
allowedUsersOverride.tipsId ??
'models.accessSettings.allowedUsers.tips'
}),
value: allowedUsersOverride.policyValue value: allowedUsersOverride.policyValue
} }
: { : {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.accessSettings.allowedUsers' id: 'models.accessSettings.allowedUsers'
}), }),
description: intl.formatMessage({
id: 'models.accessSettings.allowedUsers.tips'
}),
value: ALLOWED_PRINCIPALS_POLICY value: ALLOWED_PRINCIPALS_POLICY
}, },
{ {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.accessSettings.public' id: 'models.accessSettings.public'
}), }),
description: intl.formatMessage({
id: 'models.accessSettings.public.desc'
}),
value: 'public' value: 'public'
} }
]} ]}
></Radio.Group> />
</Form.Item> </Form.Item>
{allowedUsersOverride?.Action && accessPolicy === overridePolicyValue && (
<Flex
justify="space-between"
align="center"
style={{ marginBottom: 8 }}
>
<Label style={{ marginBottom: 0 }}>
{intl.formatMessage({
id: 'models.accessSettings.grantedPrincipals'
})}
</Label>
<allowedUsersOverride.Action
form={form}
routeId={currentData?.id}
action={action}
/>
</Flex>
)}
{accessPolicy === 'public' && ( {accessPolicy === 'public' && (
<div style={{ marginBlock: '16px 12px' }}> <div style={{ marginBlock: '16px 12px' }}>
<AlertBlockInfo <AlertBlockInfo
@@ -449,6 +479,13 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
message={intl.formatMessage({ message={intl.formatMessage({
id: 'models.accessSettings.public.tips' id: 'models.accessSettings.public.tips'
})} })}
overlayScrollerProps={{
styles: {
wrapper: {
paddingLeft: 0
}
}
}}
></AlertBlockInfo> ></AlertBlockInfo>
</div> </div>
)} )}
@@ -130,7 +130,9 @@ const AccessControlModal: React.FC<
<AlertBlockInfo <AlertBlockInfo
type="warning" type="warning"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
contentStyle={{ paddingInline: 0 }} contentStyle={{
paddingInline: 0
}}
message={intl.formatMessage({ message={intl.formatMessage({
id: 'models.button.accessSettings.tips' id: 'models.button.accessSettings.tips'
})} })}
+44 -12
View File
@@ -1,12 +1,13 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { import {
IconFont, IconFont,
StatusTag, StatusDot,
TagsWrapper, TagsWrapper,
TemplateCard, TemplateCard,
ThemeTag ThemeTag
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl, useNavigate } from '@umijs/max'; import { useIntl, useNavigate } from '@umijs/max';
import { Button } from 'antd'; import { Button, Tooltip } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -192,6 +193,23 @@ const ModelItem: React.FC<{
return _.round(max_tokens / 1024); return _.round(max_tokens / 1024);
}, [model]); }, [model]);
// Ready is the normal state — show just the dot (no label); other states
// (Not Ready / Stopped) keep their label so the problem is legible.
const statusNode = (
<StatusDot
statusValue={{
status: MyModelsStatusMap[model.status],
text:
model.status === MyModelsStatusValueMap.Ready ||
!MyModelsStatusLabelMap[model.status]
? ''
: intl.formatMessage({
id: MyModelsStatusLabelMap[model.status]
})
}}
/>
);
return ( return (
<CardWrapper> <CardWrapper>
<TemplateCard <TemplateCard
@@ -210,18 +228,32 @@ const ModelItem: React.FC<{
) : ( ) : (
<ModelLogo src={defaultModelLogo} alt="" /> <ModelLogo src={defaultModelLogo} alt="" />
)} )}
<span className="flex-center" style={{ gap: 8, minWidth: 0 }}>
<span>{model.name}</span> <span>{model.name}</span>
</span> {/* Status moved to a compact dot right after the name, freeing
<StatusTag the header's right side for the price summary. The dot keeps
maxTooltipWidth={400} the state message on hover (StatusDot has no built-in one). */}
statusValue={{ <span
status: MyModelsStatusMap[model.status], style={{
text: intl.formatMessage({ fontWeight: 400,
id: MyModelsStatusLabelMap[model.status] || '' fontSize: 'var(--font-size-small)'
}),
message: model.state_message
}} }}
></StatusTag> >
{model.state_message ? (
<Tooltip title={model.state_message}>
<span style={{ display: 'inline-flex' }}>
{statusNode}
</span>
</Tooltip>
) : (
statusNode
)}
</span>
</span>
</span>
<span className="flex-center gap-8">
<PluginExtraFields name="ModelPriceSummary" context={{ model }} />
</span>
</Header> </Header>
} }
> >
+21 -1
View File
@@ -248,7 +248,13 @@ const Models = forwardRef((props, ref) => {
chunkInstanceRequedtRef.current = setModelInstanceChunkRequest({ chunkInstanceRequedtRef.current = setModelInstanceChunkRequest({
url: `${MODEL_INSTANCE_API}`, url: `${MODEL_INSTANCE_API}`,
params: {}, params: {},
handler: updateInstanceHandler handler: updateInstanceHandler,
beforeReconnect() {
// treat the reconnect snapshot as the new baseline, otherwise
// instances deleted while the stream was down linger in the cache
// (their DELETE events are never re-sent)
cacheInsDataListRef.current = [];
}
}); });
} catch (error) { } catch (error) {
// ignore // ignore
@@ -452,6 +458,20 @@ const Models = forwardRef((props, ref) => {
}; };
}, []); }, []);
// watch events can still be lost (stream hiccup, reconnect gap); a low
// frequency relist keeps the instance cache eventually consistent, so a
// missed DELETE event can't leave a stale instance behind for good
useEffect(() => {
const timer = setInterval(() => {
if (!isPageHidden.current) {
getAllModelInstances();
}
}, 60 * 1000);
return () => {
clearInterval(timer);
};
}, []);
const setDisableExpand = useMemoizedFn((record: any) => { const setDisableExpand = useMemoizedFn((record: any) => {
return !record?.replicas; return !record?.replicas;
}); });
+31 -7
View File
@@ -153,26 +153,50 @@ const BasicForm: React.FC<BasicFormProps> = (props) => {
// Use the seed callback (not handleClusterChange) so this auto-pick refreshes // Use the seed callback (not handleClusterChange) so this auto-pick refreshes
// options without firing an evaluate request before a model is selected. // options without firing an evaluate request before a model is selected.
useEffect(() => { useEffect(() => {
if (!clusterOptions?.length) { // Options derive from clusterList: an empty source list means clusters
// are still loading — leave the field alone until they arrive.
if (!clusterList?.length) {
return; return;
} }
// Scope off the live form value, not the `useWatch` snapshot: the scope
// field's default lands in a child effect that flushes before this one,
// while the watch still reports the previous render's null — scoping off
// the watch would seed a cluster from the unscoped list here and only
// re-scope a render later.
const liveScopeOrgId = form.getFieldValue('organization_id') ?? null;
const scoped = clusterList.filter(
(item) =>
liveScopeOrgId == null || item.owner_principal_id === liveScopeOrgId
);
const current = form.getFieldValue('cluster_id'); const current = form.getFieldValue('cluster_id');
const stillValid = clusterOptions.some((c) => c.value === current); if (!scoped.length) {
// Clusters are loaded but the picked org owns none. Any leftover
// selection points at another org's cluster (seeded before the scope
// settled) and would make requests fail with "Cluster not found" —
// clear it so the required rule surfaces instead. Create only: an
// edit's cluster is existing data, not a seed.
if (action === PageAction.CREATE && current != null) {
form.setFieldValue('cluster_id', undefined);
}
return;
}
const stillValid = scoped.some((c) => c.value === current);
if (current != null && stillValid) { if (current != null && stillValid) {
return; return;
} }
const next = const next =
clusterOptions.find((c) => c.is_default)?.value ?? scoped.find((c) => c.is_default)?.value ??
clusterOptions.find((c) => c.state === ClusterStatusValueMap.Ready) scoped.find((c) => c.state === ClusterStatusValueMap.Ready)?.value ??
?.value ?? scoped[0]?.value ??
clusterOptions[0]?.value ??
null; null;
if (next == null || next === current) { if (next == null || next === current) {
return; return;
} }
form.setFieldValue('cluster_id', next); form.setFieldValue('cluster_id', next);
onClusterSeed?.(next); onClusterSeed?.(next);
}, [clusterOptions, form, onClusterSeed]); // `clusterOptions` is the re-run trigger for scope changes: it recomputes
// whenever the watched org scope or the cluster list settles.
}, [clusterOptions, clusterList, action, form, onClusterSeed]);
const clusterOptionRender = (option: any) => { const clusterOptionRender = (option: any) => {
const { data } = option; const { data } = option;
+5
View File
@@ -1,3 +1,4 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import useTableFetch from '@/hooks/use-table-fetch'; import useTableFetch from '@/hooks/use-table-fetch';
import { SyncOutlined } from '@ant-design/icons'; import { SyncOutlined } from '@ant-design/icons';
import { import {
@@ -268,6 +269,10 @@ const UserModels: React.FC = () => {
</Space> </Space>
} }
></PageTools> ></PageTools>
<PluginExtraFields
name="ModelBillingProvider"
context={{ models: dataList }}
/>
<InfiniteScrollerProvider <InfiniteScrollerProvider
value={{ value={{
total: dataSource.totalPage, total: dataSource.totalPage,
@@ -177,6 +177,7 @@ const ModelItem: React.FC<ModelItemProps> = ({
<span> <span>
<AutoComplete <AutoComplete
loading={loading} loading={loading}
allowClear
showSearch={{ showSearch={{
filterOption: (inputValue, option: any) => { filterOption: (inputValue, option: any) => {
return ( return (
@@ -300,6 +300,7 @@ const Workers: React.FC<WorkersProps> = ({ clusterId, source }) => {
></FilterBar> ></FilterBar>
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
tableLayout="auto"
columns={columns} columns={columns}
sortDirections={TABLE_SORT_DIRECTIONS} sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false} showSorterTooltip={false}
@@ -26,7 +26,7 @@ import { Tooltip } from 'antd';
import { ColumnsType } from 'antd/lib/table'; import { ColumnsType } from 'antd/lib/table';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import _ from 'lodash'; import _ from 'lodash';
import { useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import semverCoerce from 'semver/functions/coerce'; import semverCoerce from 'semver/functions/coerce';
import semverGt from 'semver/functions/gt'; import semverGt from 'semver/functions/gt';
import { status, WorkerStatusMap, WorkerStatusMapValue } from '../config'; import { status, WorkerStatusMap, WorkerStatusMapValue } from '../config';
@@ -143,15 +143,57 @@ const GPUCell = ({ devices }: { devices: GPUDeviceItem[] }) => (
</span> </span>
); );
// index + ProgressBar
const VRAMItem = ({
item,
autoOpen
}: {
item: GPUDeviceItem;
autoOpen: boolean;
}) => {
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(autoOpen);
}, [autoOpen]);
const percent = item.memory?.used
? _.round(item.memory?.utilization_rate, 0)
: _.round((item.memory?.allocated / item.memory?.total) * 100, 0);
return (
<Tooltip
title={<InfoColumn fieldList={fieldList} data={item.memory} />}
open={open}
onOpenChange={setOpen}
>
<span className="flex-center" style={{ cursor: 'pointer' }}>
<span
className="m-r-5"
style={{ display: 'flex', width: 25, lineHeight: 1.2 }}
>
<span
style={{
paddingBottom: 2,
borderBottom: '1px dashed var(--ant-blue-6)'
}}
>
[{item.index}]
</span>
</span>
<ProgressBar percent={percent} />
</span>
</Tooltip>
);
};
const VRAMCell = ({ const VRAMCell = ({
devices, devices,
intl,
rIndex, rIndex,
loadend, loadend,
firstLoad firstLoad
}: { }: {
devices: GPUDeviceItem[]; devices: GPUDeviceItem[];
intl: any;
rIndex: number; rIndex: number;
loadend: boolean; loadend: boolean;
firstLoad: boolean; firstLoad: boolean;
@@ -160,36 +202,11 @@ const VRAMCell = ({
{_.map( {_.map(
_.sortBy(devices || [], ['index']), _.sortBy(devices || [], ['index']),
(item: GPUDeviceItem, index: number) => ( (item: GPUDeviceItem, index: number) => (
<span key={index} className="flex-center"> <VRAMItem
<span key={index}
className="m-r-5" item={item}
style={{ display: 'flex', width: 25, lineHeight: 1 }} autoOpen={rIndex === 0 && index === 0 && loadend && firstLoad}
>
[{item.index}]
</span>
<ProgressBar
defaultOpen={rIndex === 0 && index === 0 && loadend && firstLoad}
percent={
item.memory?.used
? _.round(item.memory?.utilization_rate, 0)
: _.round(
(item.memory?.allocated / item.memory?.total) * 100,
0
)
}
label={<InfoColumn fieldList={fieldList} data={item.memory} />}
/> />
{item.memory.is_unified_memory && (
<Tooltip
title={intl.formatMessage({ id: 'resources.table.unified' })}
>
<InfoCircleOutlined
className="m-l-5"
style={{ color: 'var(--ant-blue-5)' }}
/>
</Tooltip>
)}
</span>
) )
)} )}
</span> </span>
@@ -498,6 +515,7 @@ const useWorkerColumns = ({
{ {
title: 'GPU', title: 'GPU',
dataIndex: 'gpu', dataIndex: 'gpu',
minWidth: 100,
render: (_, record) => render: (_, record) =>
statusAvailable(record) ? ( statusAvailable(record) ? (
<GPUCell devices={record?.status?.gpu_devices} /> <GPUCell devices={record?.status?.gpu_devices} />
@@ -508,11 +526,11 @@ const useWorkerColumns = ({
{ {
title: intl.formatMessage({ id: 'resources.table.vram' }), title: intl.formatMessage({ id: 'resources.table.vram' }),
dataIndex: 'vram', dataIndex: 'vram',
minWidth: 100,
render: (_, record, rIndex) => render: (_, record, rIndex) =>
statusAvailable(record) ? ( statusAvailable(record) ? (
<VRAMCell <VRAMCell
devices={record?.status?.gpu_devices} devices={record?.status?.gpu_devices}
intl={intl}
rIndex={rIndex} rIndex={rIndex}
loadend={loadend} loadend={loadend}
firstLoad={firstLoad} firstLoad={firstLoad}
@@ -524,6 +542,7 @@ const useWorkerColumns = ({
{ {
title: intl.formatMessage({ id: 'resources.table.disk' }), title: intl.formatMessage({ id: 'resources.table.disk' }),
dataIndex: 'storage', dataIndex: 'storage',
minWidth: 100,
render: (_, record) => ( render: (_, record) => (
<span className="flex-center flex-full"> <span className="flex-center flex-full">
{statusAvailable(record) ? ( {statusAvailable(record) ? (