Compare commits

...
Author SHA1 Message Date
ssongliu 8885463a75 feat(dashboard): add system and GPU status views (#14251) 2026-10-30 15:18:44 +08:00
ssongliu 01feaa6059 fix(ai): allow stopping and deleting restarting model containers (#14250) 2026-10-30 12:52:32 +08:00
ssongliu ecb264f087 fix(ai): clean up local model accounts during uninstall (#14249)
* fix(ai): clean up local model accounts during uninstall

* refactor(ai): restore best-effort local model account cleanup
2026-10-30 12:10:59 +08:00
9 changed files with 517 additions and 262 deletions
+1 -10
View File
@@ -1394,19 +1394,10 @@ func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
_, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return err
}
if account.AppInstallID != 0 && account.Provider != "ollama" {
_, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
if err == nil {
return buserr.WithDetail("ErrInvalidParams", "Delete the local inference engine instead", nil)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if err := global.DB.Transaction(func(tx *gorm.DB) error {
var agentCount int64
+32 -5
View File
@@ -259,6 +259,15 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
if err != nil {
return err
}
if install.Status == constant.StatusRestarting && (req.Operate == constant.Stop || req.Operate == constant.Delete) && !req.ForceDelete {
count, err := taskRepo.CountExecutingTask(repo.WithByType(task.TaskScopeApp), taskRepo.WithResourceID(install.ID), taskRepo.WithOperate(task.TaskRestart))
if err != nil {
return err
}
if count > 0 {
return buserr.New("TaskIsExecuting")
}
}
if !req.ForceDelete && !files.NewFileOp().Stat(install.GetPath()) {
return buserr.New("ErrInstallDirNotFound")
}
@@ -342,9 +351,6 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
return errors.New("lifecycle script operation not supported")
}
install.Message = ""
if err := appInstallRepo.Save(context.Background(), &install); err != nil {
return err
}
operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil {
@@ -355,10 +361,17 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
}
return err
}
ready := make(chan error, 1)
started := make(chan struct{})
operation := string(req.Operate)
operationTask.AddSubTaskWithOps(
task.GetTaskName(install.Name, taskType, task.TaskScopeApp),
func(t *task.Task) error {
if err := appInstallRepo.Save(context.Background(), &install); err != nil {
return err
}
close(started)
ready <- nil
if err := runScript(t, &install, operation); err != nil {
return err
}
@@ -385,6 +398,12 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
)
go func() {
if taskErr := operationTask.Execute(); taskErr != nil {
select {
case <-started:
default:
ready <- taskErr
return
}
if onFailure != nil {
onFailure(taskErr)
return
@@ -394,7 +413,7 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
_ = appInstallRepo.Save(context.Background(), &install)
}
}()
return nil
return <-ready
}
func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
@@ -1036,8 +1055,16 @@ func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
}
switch appInstall.Status {
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
constant.StatusStarting, constant.StatusWaiting:
return nil
case constant.StatusRestarting:
count, err := taskRepo.CountExecutingTask(repo.WithByType(task.TaskScopeApp), taskRepo.WithResourceID(appInstall.ID), taskRepo.WithOperate(task.TaskRestart))
if err != nil {
return err
}
if count > 0 {
return nil
}
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
if !force {
return nil
+2
View File
@@ -316,6 +316,8 @@ func (t *Task) ExecuteQueued(slots chan struct{}, waitingStep string, onFinish f
func (t *Task) Execute() error {
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
_ = t.logFile.Close()
global.RemoveTaskCancel(t.TaskID)
return err
}
return t.executeRegistered()
@@ -357,7 +357,9 @@ const deploymentButtons = [
{
label: i18n.global.t('commons.button.delete'),
permission: true,
disabled: (row: Deployment) => operationDisabled(row) && row.status !== 'Uninstalling',
disabled: (row: Deployment) =>
deleteLoading.value ||
(operationDisabled(row) && !['Uninstalling', 'ReStarting', 'Restarting'].includes(row.status)),
click: (row: Deployment) => {
deleting.value = row;
forceDelete.value = false;
+20 -14
View File
@@ -230,8 +230,13 @@
</el-table-column>
<el-table-column :label="$t('commons.table.status')" min-width="150">
<template #default="{ row }">
<el-dropdown placement="bottom" :disabled="statusDisabled(row)">
<Status v-permission :status="row.status" operate :disabled="statusDisabled(row)" />
<el-dropdown placement="bottom" :disabled="statusDisabled(row, 'stop')">
<Status
v-permission
:status="row.status"
operate
:disabled="statusDisabled(row, 'stop')"
/>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
@@ -242,7 +247,8 @@
</el-dropdown-item>
<el-dropdown-item
:disabled="
statusDisabled(row) || ['Stopped', 'Exited'].includes(row.status)
statusDisabled(row, 'stop') ||
['Stopped', 'Exited'].includes(row.status)
"
@click="operate(row, 'stop')"
>
@@ -556,7 +562,6 @@ const accountButtons = [
{
label: i18n.global.t('commons.button.delete'),
permission: true,
show: (row: AI.AgentAccountItem) => !row.appInstallId || row.provider === 'ollama',
click: async (row: AI.AgentAccountItem) => {
await ElMessageBox.confirm(
i18n.global.t('commons.msg.delete', [row.name]),
@@ -570,22 +575,23 @@ const accountButtons = [
];
const taskRef = ref();
const pending = ref('');
const operationDisabled = (row: Deployment) =>
const operationDisabled = (row: Deployment, operation = '') =>
Boolean(pending.value) ||
['Installing', 'Starting', 'ReStarting', 'Restarting', 'Rebuilding', 'Waiting', 'Uninstalling'].includes(
row.status,
);
const statusDisabled = (row: Deployment) =>
operationDisabled(row) || !hasManagePermissionAccess() || (row.engine === 'ollama' && !hasPermission('app_manage'));
['Installing', 'Starting', 'Rebuilding', 'Waiting', 'Uninstalling'].includes(row.status) ||
(['ReStarting', 'Restarting'].includes(row.status) && !['stop', 'delete'].includes(operation));
const statusDisabled = (row: Deployment, operation = '') =>
operationDisabled(row, operation) ||
!hasManagePermissionAccess() ||
(row.engine === 'ollama' && !hasPermission('app_manage'));
const operate = async (row: Deployment, operation: 'start' | 'stop' | 'restart') => {
if (statusDisabled(row)) return;
if (statusDisabled(row, operation)) return;
const node = currentNode.value;
await ElMessageBox.confirm(
i18n.global.t('app.operatorHelper', [i18n.global.t('commons.operate.' + operation)]),
i18n.global.t('commons.operate.' + operation),
{ type: 'warning' },
);
if (node !== currentNode.value || statusDisabled(row)) return;
if (node !== currentNode.value || statusDisabled(row, operation)) return;
pending.value = `${row.engine}:${row.id}`;
try {
if (row.engine === 'ollama') {
@@ -653,7 +659,7 @@ const deploymentButtons = [
label: i18n.global.t('commons.button.delete'),
permission: true,
show: (row: Deployment) => row.engine === 'ollama' && hasPermission('app_manage'),
disabled: operationDisabled,
disabled: (row: Deployment) => operationDisabled(row, 'delete'),
click: async (row: Deployment) => {
const node = currentNode.value;
const path = route.fullPath;
@@ -683,7 +689,7 @@ const deploymentButtons = [
label: i18n.global.t('commons.button.delete'),
permission: true,
show: (row: Deployment) => row.engine === 'vllm',
disabled: (row: Deployment) => operationDisabled(row) && row.status !== 'Uninstalling',
disabled: (row: Deployment) => operationDisabled(row, 'delete') && row.status !== 'Uninstalling',
click: (row: Deployment) => manageRef.value.open(row, 'delete', true),
},
];
@@ -211,7 +211,7 @@ const buttons = [
label: i18n.global.t('commons.button.stop'),
permission: true,
disabled: (row: AI.TensorRTLLM) => {
return row.status !== 'Running';
return !['Running', 'ReStarting', 'Restarting'].includes(row.status);
},
click: (row: AI.TensorRTLLM) => {
operate(row, 'stop');
+18 -1
View File
@@ -72,8 +72,14 @@
</template>
</CardWithHeader>
<CardWithHeader :header="$t('commons.table.status')" class="card-interval">
<template #header-r>
<el-radio-group v-if="hasGPU" v-model="statusView">
<el-radio-button value="system">{{ $t('menu.system') }}</el-radio-button>
<el-radio-button value="gpu">GPU</el-radio-button>
</el-radio-group>
</template>
<template #body>
<SystemStatus ref="statusRef" style="margin-bottom: 33px" />
<SystemStatus ref="statusRef" :view="statusView" style="margin-bottom: 33px" />
</template>
</CardWithHeader>
<CardWithHeader
@@ -544,6 +550,7 @@ const welcomeOpen = ref();
const shadowContainer = ref();
const chartOption = ref('network');
const statusOption = ref(localStorage.getItem('dashboard_status_view') === 'gpu' ? 'gpu' : 'system');
let timer: ReturnType<typeof setInterval> | null = null;
let initialSampleTimer: ReturnType<typeof setTimeout> | null = null;
let isInit = ref<boolean>(true);
@@ -678,6 +685,16 @@ const currentInfo = ref<Dashboard.CurrentInfo>({
shotTime: new Date(),
});
const hasGPU = computed(() =>
[currentInfo.value.gpuData, currentInfo.value.npuData, currentInfo.value.xpuData].some((items) => items?.length),
);
const statusView = computed({
get: () => (hasGPU.value && statusOption.value === 'gpu' ? 'gpu' : 'system'),
set: (value: 'system' | 'gpu') => {
statusOption.value = value;
localStorage.setItem('dashboard_status_view', value);
},
});
const currentChartInfo = reactive({
ioReadBytes: 0,
ioWriteBytes: 0,
+407
View File
@@ -0,0 +1,407 @@
<template>
<div class="gpu-status">
<div class="gpu-summary">
<div class="gpu-summary-header">
<span class="metric-label">{{ $t('aiTools.gpu.deviceInfo') }}</span>
<el-button link type="primary" size="small" @click="$emit('open')">
{{ $t('aiTools.gpu.gpu') }}
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
<div class="summary-devices">
<div class="device-counts">
<strong v-for="group in deviceGroups" :key="group.type">
{{ group.type }} × {{ group.count }}
</strong>
</div>
<div class="model-list">
<span v-for="model in modelGroups" :key="model.name">{{ model.name }} × {{ model.count }}</span>
</div>
</div>
<div class="summary-memory">
<span class="metric-label">{{ $t('aiTools.gpu.memoryTotal') }}</span>
<strong>{{ formatMemory(totalMemory) }}</strong>
<span class="metric-label">{{ $t('home.used') }} {{ formatMemory(usedMemory) }}</span>
</div>
</div>
<div class="gpu-grid">
<article v-for="device in visibleDevices" :key="device.key" class="gpu-card">
<div class="device-header">
<span class="device-id">{{ device.id }}</span>
<el-popover :width="360" trigger="click" popper-class="dashboard-gpu-popover">
<el-descriptions :title="device.name" :column="1" size="small">
<el-descriptions-item
v-for="detail in device.details.filter((item) => isAvailable(item[1]))"
:key="detail[0]"
:label="detail[0]"
>
{{ detail[1] }}
</el-descriptions-item>
</el-descriptions>
<template #reference>
<el-button link type="primary" size="small">{{ $t('commons.button.view') }}</el-button>
</template>
</el-popover>
</div>
<div class="device-identity">
<h3>{{ device.name }}</h3>
<span class="memory-capacity">{{ formatMemory(device.memoryTotal) }}</span>
</div>
<div class="device-metric">
<div class="metric-heading">
<span class="metric-label">
{{ device.type === 'NPU' ? $t('aiTools.gpu.aiCore') : $t('aiTools.gpu.gpuUtil') }}
</span>
<strong>{{ device.utilization === null ? '—' : `${device.utilization}%` }}</strong>
</div>
<el-progress :percentage="device.utilization ?? 0" :show-text="false" :stroke-width="6" />
</div>
<div class="device-metric">
<div class="metric-heading">
<span class="metric-label">{{ $t('aiTools.gpu.memoryUsed') }}</span>
<strong>
{{ formatMemory(device.memoryUsed) }}
<span class="metric-total">/ {{ formatMemory(device.memoryTotal) }}</span>
</strong>
</div>
<el-progress
:percentage="device.memoryPercent ?? 0"
:show-text="false"
:stroke-width="6"
color="var(--el-color-success)"
/>
</div>
<div class="device-footer">
<div>
<span class="metric-label">{{ $t('aiTools.gpu.temperature') }}</span>
<strong>{{ device.temperature }}</strong>
</div>
<div>
<span class="metric-label">{{ $t('aiTools.gpu.powerUsage') }}</span>
<strong>{{ device.power }}</strong>
</div>
</div>
</article>
</div>
<div v-if="devices.length > 4" class="gpu-expand">
<el-button link type="primary" @click="showAll = !showAll">
{{ $t(showAll ? 'tabs.hide' : 'tabs.more') }}
<el-icon>
<ArrowUp v-if="showAll" />
<ArrowDown v-else />
</el-icon>
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ArrowDown, ArrowRight, ArrowUp } from '@element-plus/icons-vue';
import { Dashboard } from '@/api/interface/dashboard';
import { computeSizeForDocker } from '@/utils/size';
import i18n from '@/lang';
const props = defineProps<{ current: Dashboard.CurrentInfo }>();
defineEmits<{ open: [] }>();
const showAll = ref(false);
const t = i18n.global.t;
const isAvailable = (value?: string) =>
Boolean(value?.trim() && !/^(N\/A|NA|NOT SUPPORTED|\[NOT SUPPORTED\])$/i.test(value.trim()));
const memoryBytes = (value?: string): number | null => {
const match = value?.trim().match(/^(\d+(?:\.\d+)?)\s*(B|bytes?|[KMGT]i?B)?$/i);
if (!match) return null;
const units: Record<string, number> = {
'': 1024 ** 2,
b: 1,
byte: 1,
bytes: 1,
kb: 1000,
kib: 1024,
mb: 1000 ** 2,
mib: 1024 ** 2,
gb: 1000 ** 3,
gib: 1024 ** 3,
tb: 1000 ** 4,
tib: 1024 ** 4,
};
const bytes = Number(match[1]) * units[(match[2] || '').toLowerCase()];
return Number.isFinite(bytes) ? bytes : null;
};
const formatMemory = (value: number | null) => (value === null ? '—' : computeSizeForDocker(value));
const devices = computed(() =>
[
...(props.current.gpuData || []).map((item) => ({
key: `gpu-${item.type}-${item.busID || item.index}`,
type: 'GPU',
card: `${item.type}-${item.busID || item.index}`,
id: `GPU ${item.index}`,
name: item.productName || 'GPU',
util: item.gpuUtil,
used: item.memUsed || item.memoryUsage?.split('/')[0],
total: item.memTotal || item.memoryUsage?.split('/')[1],
temperature: item.temperature,
power: item.powerUsage || item.powerDraw,
details: [
[t('aiTools.gpu.busID'), item.busID],
[t('aiTools.gpu.fanSpeed'), item.fanSpeed],
[t('aiTools.gpu.performanceState'), item.performanceState],
[t('aiTools.gpu.powerUsage'), item.powerUsage || item.powerDraw],
],
})),
...(props.current.npuData || []).map((item) => ({
key: `npu-${item.npuIndex}-${item.chipIndex}`,
type: 'NPU',
card: item.npuIndex,
id: `NPU ${item.npuIndex} · Chip ${item.chipIndex}`,
name: item.productName || 'NPU',
util: item.aiCore,
used: item.memUsed,
total: item.memTotal,
temperature: item.temperature,
power: item.powerDraw,
details: [
[t('aiTools.gpu.busID'), item.busID],
[t('commons.table.status'), item.health],
[t('aiTools.gpu.ddrUsage'), `${item.memoryUsed || '—'} / ${item.memoryTotal || '—'}`],
[t('aiTools.gpu.hbmUsage'), `${item.hbmUsed || '—'} / ${item.hbmTotal || '—'}`],
[t('aiTools.gpu.hugepagesUsage'), `${item.hugepagesUsed || '—'} / ${item.hugepagesTotal || '—'}`],
],
})),
...(props.current.xpuData || []).map((item) => ({
key: `xpu-${item.deviceID}`,
type: 'XPU',
card: item.deviceID,
id: `XPU ${item.deviceID}`,
name: item.deviceName || 'XPU',
util: item.gpuUtil,
used: item.memoryUsed,
total: item.memory,
temperature: item.temperature,
power: item.power,
details: [[t('aiTools.gpu.busID'), item.pciBdfAddress]],
})),
].map((item) => {
const memoryUsed = memoryBytes(item.used);
const memoryTotal = memoryBytes(item.total);
const utilization = item.util?.trim().match(/^(\d+(?:\.\d+)?)\s*%?$/);
return {
...item,
memoryUsed,
memoryTotal,
utilization: utilization ? Math.min(100, Number(utilization[1])) : null,
memoryPercent:
memoryUsed !== null && memoryTotal !== null && memoryTotal > 0
? Math.min(100, Number(((memoryUsed / memoryTotal) * 100).toFixed(1)))
: null,
temperature: isAvailable(item.temperature) ? item.temperature.replace(/\s*°?C\b/, ' °C') : '—',
power: isAvailable(item.power) ? item.power : '—',
};
}),
);
const visibleDevices = computed(() => (showAll.value ? devices.value : devices.value.slice(0, 4)));
const deviceGroups = computed(() =>
['GPU', 'NPU', 'XPU']
.map((type) => ({
type,
count: new Set(devices.value.filter((item) => item.type === type).map((item) => item.card)).size,
}))
.filter((item) => item.count > 0),
);
const modelGroups = computed(() => {
const models = new Map<string, Set<string>>();
for (const device of devices.value) {
if (!models.has(device.name)) models.set(device.name, new Set());
models.get(device.name)!.add(`${device.type}-${device.card}`);
}
return Array.from(models, ([name, cards]) => ({ name, count: cards.size }));
});
const totalMemory = computed(() =>
devices.value.some((item) => item.memoryTotal === null)
? null
: devices.value.reduce((sum, item) => sum + item.memoryTotal!, 0),
);
const usedMemory = computed(() =>
devices.value.some((item) => item.memoryUsed === null)
? null
: devices.value.reduce((sum, item) => sum + item.memoryUsed!, 0),
);
</script>
<style scoped lang="scss">
.gpu-status {
container-type: inline-size;
grid-column: 1 / -1;
min-width: 0;
color: var(--el-text-color-primary);
}
.gpu-summary {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px 24px;
padding: 18px 20px;
margin-bottom: 16px;
border-radius: 8px;
background: var(--el-fill-color-light);
}
.gpu-summary-header {
display: flex;
flex: 0 0 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.metric-label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.summary-devices {
flex: 1 1 200px;
min-width: 0;
}
.device-counts {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin: 8px 0;
font-size: 22px;
}
.model-list {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 12px;
color: var(--el-text-color-regular);
overflow-wrap: anywhere;
}
.summary-memory {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
padding-left: 24px;
border-left: 1px solid var(--el-border-color-lighter);
}
.summary-memory > strong {
font-size: 22px;
font-weight: 600;
}
.gpu-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.gpu-card {
padding: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
min-width: 0;
background: var(--el-bg-color);
}
.device-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.device-id {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.device-identity {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
margin: 10px 0 20px;
}
.device-identity h3 {
flex: 1 1 150px;
margin: 0;
font-size: 15px;
line-height: 1.5;
font-weight: 600;
overflow-wrap: anywhere;
}
.memory-capacity {
padding: 3px 7px;
border-radius: 4px;
font-size: 12px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
white-space: nowrap;
}
.device-metric + .device-metric {
margin-top: 16px;
}
.metric-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 4px 8px;
margin-bottom: 8px;
}
.metric-heading strong {
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.metric-total {
font-weight: 400;
color: var(--el-text-color-secondary);
}
.device-footer {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
justify-content: space-between;
margin-top: 18px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-extra-light);
}
.device-footer > div {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
}
.device-footer strong {
font-size: 12px;
font-weight: 500;
}
.gpu-expand {
margin-top: 16px;
text-align: center;
}
@container (max-width: 560px) {
.gpu-grid {
grid-template-columns: minmax(0, 1fr);
}
.gpu-summary {
padding: 16px;
flex-wrap: wrap;
gap: 16px;
}
.summary-memory {
padding-left: 0;
border-left: 0;
}
}
</style>
<style lang="scss">
.dashboard-gpu-popover.el-popover {
max-width: calc(100vw - 24px);
max-height: calc(100dvh - 24px);
overflow: auto;
box-sizing: border-box;
}
</style>
+33 -230
View File
@@ -1,6 +1,6 @@
<template>
<div class="custom-row">
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center">
<el-col v-if="view === 'system'" :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center">
<el-popover
:hide-after="20"
:teleported="false"
@@ -52,7 +52,7 @@
</el-popover>
<span class="input-help">{{ loadStatus(currentInfo.loadUsagePercent) }}</span>
</el-col>
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3">
<el-col v-if="view === 'system'" :xs="6" :sm="6" :md="3" :lg="3" :xl="3">
<el-popover
:hide-after="20"
:teleported="false"
@@ -160,7 +160,7 @@
</span>
</div>
</el-col>
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center">
<el-col v-if="view === 'system'" :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center">
<el-popover
:hide-after="20"
:teleported="false"
@@ -254,7 +254,15 @@
</span>
</el-col>
<template v-for="(item, index) of currentInfo.diskData" :key="index">
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center" v-if="isShow('disk', index)">
<el-col
:xs="6"
:sm="6"
:md="3"
:lg="3"
:xl="3"
align="center"
v-if="view === 'system' && (showMore || index < (currentInfo.diskData.length > 5 ? 4 : 5))"
>
<el-popover
:hide-after="20"
:teleported="false"
@@ -310,157 +318,16 @@
<span class="input-help">{{ computeSize(item.used) }} / {{ computeSize(item.total) }}</span>
</el-col>
</template>
<template v-for="(item, index) of currentInfo.gpuData" :key="index">
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center" v-if="isShow('gpu', index)">
<el-popover
:hide-after="20"
:teleported="false"
:width="450"
popper-class="dashboard-status-popover"
v-if="chartsOption[`gpu${index}`]"
>
<el-descriptions :title="item.productName" direction="vertical" :column="3" size="small">
<el-descriptions-item :label="$t('aiTools.gpu.gpuUtil')">
{{ item.gpuUtil }}
</el-descriptions-item>
<el-descriptions-item :label="$t('aiTools.gpu.temperature')">
{{ formatDashboardTemperature(item.temperature) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('aiTools.gpu.memoryUsage')">
{{ item.memoryUsage }}
</el-descriptions-item>
<el-descriptions-item v-if="hasField(item.busID)" :label="$t('aiTools.gpu.busID')">
{{ item.busID }}
</el-descriptions-item>
<el-descriptions-item v-if="hasField(item.fanSpeed)" :label="$t('aiTools.gpu.fanSpeed')">
{{ item.fanSpeed }}
</el-descriptions-item>
<el-descriptions-item
v-if="hasField(item.performanceState)"
:label="$t('aiTools.gpu.performanceState')"
>
{{ item.performanceState }}
</el-descriptions-item>
<el-descriptions-item v-if="hasField(item.powerDraw)" :label="$t('aiTools.gpu.powerUsage')">
{{ item.powerUsage }}
</el-descriptions-item>
</el-descriptions>
<template #reference>
<v-charts
@click="goGPU()"
height="160px"
:id="`gpu${index}`"
type="pie"
:option="chartsOption[`gpu${index}`]"
v-if="chartsOption[`gpu${index}`]"
/>
</template>
</el-popover>
<el-tooltip :content="item.productName" v-if="item.productName.length > 25">
<span class="input-help">{{ item.productName.substring(0, 22) }}...</span>
</el-tooltip>
<span class="input-help" v-else>{{ item.productName }}</span>
</el-col>
</template>
<template v-for="(item, index) of currentInfo.npuData" :key="index">
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center" v-if="isShow('npu', index)">
<el-popover
:hide-after="20"
:teleported="false"
:width="450"
popper-class="dashboard-status-popover"
v-if="chartsOption[`npu${index}`]"
>
<el-descriptions :title="item.productName" direction="vertical" :column="3" size="small">
<el-descriptions-item v-if="hasField(item.aiCore)" label="AICore(%)">
{{ item.aiCore }}
</el-descriptions-item>
<el-descriptions-item v-if="hasField(item.temperature)" :label="$t('aiTools.gpu.temperature')">
{{ formatDashboardTemperature(item.temperature) }}
</el-descriptions-item>
<el-descriptions-item
v-if="hasMetricPair(item.memUsed, item.memTotal)"
:label="$t('aiTools.gpu.memoryUsage')"
>
{{ formatMetricPair(item.memUsed, item.memTotal) }}
</el-descriptions-item>
<el-descriptions-item v-if="hasField(item.powerDraw)" :label="$t('aiTools.gpu.powerUsage')">
{{ item.powerDraw }}
</el-descriptions-item>
<el-descriptions-item
v-if="hasMetricPair(item.hugepagesUsed, item.hugepagesTotal)"
label="Hugepages-Usage(page)"
>
{{ formatMetricPair(item.hugepagesUsed, item.hugepagesTotal) }}
</el-descriptions-item>
<el-descriptions-item v-if="hasField(item.busID)" :label="$t('aiTools.gpu.busID')">
{{ item.busID }}
</el-descriptions-item>
<el-descriptions-item v-if="hasMetricPair(item.hbmUsed, item.hbmTotal)" label="HBM-Usage">
{{ formatMetricPair(item.hbmUsed, item.hbmTotal) }}
</el-descriptions-item>
</el-descriptions>
<template #reference>
<v-charts
@click="goGPU()"
height="160px"
:id="`npu${index}`"
type="pie"
:option="chartsOption[`npu${index}`]"
v-if="chartsOption[`npu${index}`]"
/>
</template>
</el-popover>
<el-tooltip :content="item.productName" v-if="item.productName.length > 25">
<span class="input-help">{{ item.productName.substring(0, 22) }}...</span>
</el-tooltip>
<span class="input-help" v-else>{{ item.productName }}</span>
</el-col>
</template>
<template v-for="(item, index) of currentInfo.xpuData" :key="index">
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center" v-if="isShow('xpu', index)">
<el-popover
:hide-after="20"
:teleported="false"
:width="400"
popper-class="dashboard-status-popover"
v-if="chartsOption[`xpu${index}`]"
>
<el-descriptions :title="item.deviceName" direction="vertical" :column="3" size="small">
<el-descriptions-item v-if="hasField(item.gpuUtil)" :label="$t('aiTools.gpu.gpuUtil')">
{{ item.gpuUtil }}
</el-descriptions-item>
<el-descriptions-item v-if="hasField(item.temperature)" :label="$t('aiTools.gpu.temperature')">
{{ item.temperature }}
</el-descriptions-item>
<el-descriptions-item :label="$t('aiTools.gpu.memoryUsage')">
{{ item.memoryUsed }}/{{ item.memory }}
</el-descriptions-item>
<el-descriptions-item v-if="hasField(item.pciBdfAddress)" :label="$t('aiTools.gpu.busID')">
{{ item.pciBdfAddress }}
</el-descriptions-item>
<el-descriptions-item v-if="hasField(item.power)" :label="$t('aiTools.gpu.powerUsage')">
{{ item.power }}
</el-descriptions-item>
</el-descriptions>
<template #reference>
<v-charts
@click="goGPU()"
height="160px"
:id="`xpu${index}`"
type="pie"
:option="chartsOption[`xpu${index}`]"
v-if="chartsOption[`xpu${index}`]"
/>
</template>
</el-popover>
<el-tooltip :content="item.deviceName" v-if="item.deviceName.length > 25">
<span class="input-help">{{ item.deviceName.substring(0, 22) }}...</span>
</el-tooltip>
<span class="input-help" v-else>{{ item.deviceName }}</span>
</el-col>
</template>
<el-col :xs="6" :sm="6" :md="3" :lg="3" :xl="3" align="center" v-if="totalCount > 5">
<GPUStatus v-if="view === 'gpu'" :current="currentInfo" @open="routerToName('GPU')" />
<el-col
:xs="6"
:sm="6"
:md="3"
:lg="3"
:xl="3"
align="center"
v-if="view === 'system' && currentInfo.diskData.length > 5"
>
<el-button v-if="!showMore" link type="primary" @click="changeShowMore(true)" class="buttonClass">
{{ $t('tabs.more') }}
<el-icon><Bottom /></el-icon>
@@ -478,7 +345,8 @@
import { Dashboard } from '@/api/interface/dashboard';
import { computeSize } from '@/utils/size';
import i18n from '@/lang';
import { nextTick, onBeforeUnmount, ref } from 'vue';
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import GPUStatus from './gpu.vue';
import { useMediaQuery } from '@vueuse/core';
import { routerToFileWithPath, routerToName } from '@/utils/router';
import { stopProcess } from '@/api/modules/process';
@@ -486,9 +354,8 @@ import { loadTopCPU, loadTopMem } from '@/api/modules/dashboard';
import { MsgSuccess } from '@/utils/message';
const hasFinePointer = useMediaQuery('(hover: hover) and (pointer: fine)');
const props = defineProps<{ view: 'system' | 'gpu' }>();
const showMore = ref(false);
const totalCount = ref();
let cpuPopoverTimer: ReturnType<typeof setTimeout> | null = null;
let memPopoverTimer: ReturnType<typeof setTimeout> | null = null;
let cpuLoading = false;
@@ -606,57 +473,10 @@ const acceptParams = (current: Dashboard.CurrentInfo, base: Dashboard.BaseInfo):
data: formatNumber(currentInfo.value.diskData[i].usedPercent),
};
}
currentInfo.value.gpuData = currentInfo.value.gpuData || [];
for (let i = 0; i < currentInfo.value.gpuData.length; i++) {
chartsOption.value['gpu' + i] = {
title: 'GPU-' + currentInfo.value.gpuData[i].index,
data: metricPercentage(currentInfo.value.gpuData[i].gpuUtil),
};
}
currentInfo.value.npuData = currentInfo.value.npuData || [];
for (let i = 0; i < currentInfo.value.npuData.length; i++) {
chartsOption.value['npu' + i] = {
title: 'NPU-' + currentInfo.value.npuData[i].npuIndex + '/' + currentInfo.value.npuData[i].chipIndex,
data: metricPercentage(currentInfo.value.npuData[i].aiCore),
};
}
currentInfo.value.xpuData = currentInfo.value.xpuData || [];
for (let i = 0; i < currentInfo.value.xpuData.length; i++) {
chartsOption.value['xpu' + i] = {
title: 'XPU-' + currentInfo.value.xpuData[i].deviceID,
data: metricPercentage(currentInfo.value.xpuData[i].gpuUtil || currentInfo.value.xpuData[i].memoryUtil),
};
}
totalCount.value =
currentInfo.value.diskData.length +
currentInfo.value.gpuData.length +
currentInfo.value.npuData.length +
currentInfo.value.xpuData.length;
showMore.value = localStorage.getItem('dashboard_show') === 'more';
});
};
const isShow = (val: string, index: number) => {
let showCount = totalCount.value < 6 ? 5 : 4;
switch (val) {
case 'disk':
return showMore.value || index < showCount;
case 'gpu':
let gpuCount = showCount - currentInfo.value.diskData.length;
return showMore.value || index < gpuCount;
case 'npu':
let npuCount = showCount - currentInfo.value.diskData.length - currentInfo.value.gpuData.length;
return showMore.value || index < npuCount;
case 'xpu':
let xpuCount =
showCount -
currentInfo.value.diskData.length -
currentInfo.value.gpuData.length -
currentInfo.value.npuData.length;
return showMore.value || index < xpuCount;
}
};
const changeShowMore = (show: boolean) => {
showMore.value = show;
localStorage.setItem('dashboard_show', show ? 'more' : 'hide');
@@ -690,35 +510,10 @@ function loadStatus(val: number) {
return i18n.global.t('home.runJam');
}
const goGPU = () => {
routerToName('GPU');
};
function formatNumber(val: number) {
return Number(val.toFixed(2));
}
const hasField = (value?: string) => {
return typeof value === 'string' && value.trim() !== '';
};
const hasMetricPair = (used?: string, total?: string) => {
return hasField(used) || hasField(total);
};
const formatMetricPair = (used?: string, total?: string) => {
return `${used || 'N/A'} / ${total || 'N/A'}`;
};
const formatDashboardTemperature = (value: string) => {
return value.replace(/\s*°?C\b/, ' °C');
};
const metricPercentage = (value?: string) => {
const matched = value?.match(/[0-9]+(?:\.[0-9]+)?/);
return matched ? formatNumber(Number.parseFloat(matched[0])) : 0;
};
const normalizeDashboardAccelerators = (current: Dashboard.CurrentInfo) => {
const legacyNPUs = (current.gpuData || [])
.filter((item) => item.type === 'ascend')
@@ -820,6 +615,14 @@ const loadTopMemData = async () => {
}
};
watch(
() => props.view,
() => {
onCpuPopoverHide();
onMemPopoverHide();
},
);
onBeforeUnmount(() => {
if (cpuPopoverTimer) {
clearInterval(Number(cpuPopoverTimer));