2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 06:47:20 +08:00
Files
panel/web/src/views/toolbox/ProcessView.vue
2026-01-09 01:28:48 +08:00

494 lines
14 KiB
Vue

<script setup lang="ts">
import type { DataTableSortState, DropdownOption } from 'naive-ui'
import { NTag } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import process, { type ProcessListParams } from '@/api/panel/process'
import { formatBytes, formatDateTime, formatPercent } from '@/utils'
const { $gettext } = useGettext()
// 排序状态
const sortState = ref<DataTableSortState | null>(null)
const sortKeyMapOrder = computed(() => {
if (!sortState.value || !sortState.value.order) return {}
return { [sortState.value.columnKey]: sortState.value.order }
})
// 筛选状态
const statusFilter = ref<string>('')
const keyword = ref<string>('')
// 右键菜单相关
const showDropdown = ref(false)
const selectedRow = ref<any>(null)
const dropdownX = ref(0)
const dropdownY = ref(0)
// 进程详情弹窗
const detailModal = ref(false)
const detailLoading = ref(false)
const processDetail = ref<any>(null)
// 信号定义
const SIGNALS = {
SIGHUP: 1, // 挂起
SIGINT: 2, // 中断 (Ctrl+C)
SIGKILL: 9, // 强制终止
SIGTERM: 15, // 终止
SIGCONT: 18, // 继续
SIGSTOP: 19, // 暂停
SIGUSR1: 10, // 用户自定义信号1
SIGUSR2: 12 // 用户自定义信号2
}
// 状态选项
const statusOptions = [
{ label: $gettext('All Status'), value: '' },
{ label: $gettext('Running'), value: 'R' },
{ label: $gettext('Sleeping'), value: 'S' },
{ label: $gettext('Stopped'), value: 'T' },
{ label: $gettext('Idle'), value: 'I' },
{ label: $gettext('Zombie'), value: 'Z' },
{ label: $gettext('Waiting'), value: 'W' },
{ label: $gettext('Locked'), value: 'L' }
]
// 右键菜单选项
const dropdownOptions = computed<DropdownOption[]>(() => {
if (!selectedRow.value) return []
return [
{ label: $gettext('View Details'), key: 'detail' },
{ type: 'divider', key: 'd1' },
{ label: $gettext('Terminate (SIGTERM)'), key: 'sigterm' },
{ label: $gettext('Kill (SIGKILL)'), key: 'sigkill' },
{ type: 'divider', key: 'd2' },
{ label: $gettext('Stop (SIGSTOP)'), key: 'sigstop' },
{ label: $gettext('Continue (SIGCONT)'), key: 'sigcont' },
{ type: 'divider', key: 'd3' },
{ label: $gettext('Interrupt (SIGINT)'), key: 'sigint' },
{ label: $gettext('Hang Up (SIGHUP)'), key: 'sighup' },
{ label: $gettext('User Signal 1 (SIGUSR1)'), key: 'sigusr1' },
{ label: $gettext('User Signal 2 (SIGUSR2)'), key: 'sigusr2' }
]
})
// 渲染状态标签
const renderStatus = (status: string) => {
switch (status) {
case 'R':
return h(NTag, { type: 'success' }, { default: () => $gettext('Running') })
case 'S':
return h(NTag, { type: 'warning' }, { default: () => $gettext('Sleeping') })
case 'T':
return h(NTag, { type: 'error' }, { default: () => $gettext('Stopped') })
case 'I':
return h(NTag, { type: 'primary' }, { default: () => $gettext('Idle') })
case 'Z':
return h(NTag, { type: 'error' }, { default: () => $gettext('Zombie') })
case 'W':
return h(NTag, { type: 'warning' }, { default: () => $gettext('Waiting') })
case 'L':
return h(NTag, { type: 'info' }, { default: () => $gettext('Locked') })
default:
return h(NTag, { type: 'default' }, { default: () => status })
}
}
const columns = computed<any[]>(() => [
{
title: 'PID',
key: 'pid',
width: 160,
sortOrder: sortKeyMapOrder.value.pid || false,
sorter: true
},
{
title: $gettext('Name'),
key: 'name',
resizable: true,
sortOrder: sortKeyMapOrder.value.name || false,
sorter: true,
ellipsis: { tooltip: true }
},
{
title: $gettext('Parent PID'),
key: 'ppid',
width: 160,
sortOrder: sortKeyMapOrder.value.ppid || false,
sorter: true
},
{
title: $gettext('Threads'),
key: 'num_threads',
width: 100,
sortOrder: sortKeyMapOrder.value.num_threads || false,
sorter: true
},
{
title: $gettext('User'),
key: 'username',
width: 100,
ellipsis: { tooltip: true }
},
{
title: $gettext('Status'),
key: 'status',
width: 100,
render(row: any) {
return renderStatus(row.status)
}
},
{
title: 'CPU',
key: 'cpu',
width: 100,
sortOrder: sortKeyMapOrder.value.cpu || false,
sorter: true,
render(row: any): string {
return formatPercent(row.cpu) + '%'
}
},
{
title: $gettext('Memory'),
key: 'rss',
width: 140,
sortOrder: sortKeyMapOrder.value.rss || false,
sorter: true,
render(row: any): string {
return formatBytes(row.rss)
}
},
{
title: $gettext('Start Time'),
key: 'start_time',
width: 240,
sortOrder: sortKeyMapOrder.value.start_time || false,
sorter: true
}
])
// 行属性 - 右键菜单
const rowProps = (row: any) => {
return {
onContextmenu: (e: MouseEvent) => {
e.preventDefault()
showDropdown.value = false
nextTick().then(() => {
showDropdown.value = true
selectedRow.value = row
dropdownX.value = e.clientX
dropdownY.value = e.clientY
})
}
}
}
// 关闭右键菜单
const onCloseDropdown = () => {
showDropdown.value = false
selectedRow.value = null
}
// 处理右键菜单选择
const handleDropdownSelect = (key: string) => {
showDropdown.value = false
if (!selectedRow.value) return
const pid = selectedRow.value.pid
switch (key) {
case 'detail':
handleShowDetail(pid)
break
case 'sigterm':
handleSignal(pid, SIGNALS.SIGTERM, 'SIGTERM')
break
case 'sigkill':
handleSignal(pid, SIGNALS.SIGKILL, 'SIGKILL')
break
case 'sigstop':
handleSignal(pid, SIGNALS.SIGSTOP, 'SIGSTOP')
break
case 'sigcont':
handleSignal(pid, SIGNALS.SIGCONT, 'SIGCONT')
break
case 'sigint':
handleSignal(pid, SIGNALS.SIGINT, 'SIGINT')
break
case 'sighup':
handleSignal(pid, SIGNALS.SIGHUP, 'SIGHUP')
break
case 'sigusr1':
handleSignal(pid, SIGNALS.SIGUSR1, 'SIGUSR1')
break
case 'sigusr2':
handleSignal(pid, SIGNALS.SIGUSR2, 'SIGUSR2')
break
}
}
// 发送信号
const handleSignal = (pid: number, signal: number, signalName: string) => {
window.$dialog.warning({
title: $gettext('Confirm'),
content: $gettext('Are you sure you want to send %{ signal } to process %{ pid }?', {
signal: signalName,
pid: pid.toString()
}),
positiveText: $gettext('Confirm'),
negativeText: $gettext('Cancel'),
onPositiveClick: () => {
useRequest(process.signal(pid, signal)).onSuccess(() => {
refresh()
window.$message.success(
$gettext('Signal %{ signal } has been sent to process %{ pid }', {
signal: signalName,
pid: pid.toString()
})
)
})
}
})
}
// 显示进程详情
const handleShowDetail = (pid: number) => {
detailLoading.value = true
detailModal.value = true
useRequest(process.detail(pid))
.onSuccess(({ data }) => {
processDetail.value = data
})
.onComplete(() => {
detailLoading.value = false
})
}
// 处理排序变化
const handleSorterChange = (sorter: DataTableSortState | DataTableSortState[] | null) => {
if (Array.isArray(sorter)) {
sortState.value = sorter[0] || null
} else {
sortState.value = sorter
}
}
// 分页获取进程列表
const { loading, data, page, total, pageSize, pageCount, refresh } = usePagination(
(page, pageSize) => {
const sort = sortState.value?.columnKey as string | undefined
// descend(箭头向下) -> desc(大到小), ascend(箭头向上) -> asc(小到大)
const order = sortState.value?.order
? sortState.value.order === 'descend'
? 'desc'
: 'asc'
: undefined
const params: ProcessListParams = {
page,
limit: pageSize,
sort,
order,
status: statusFilter.value || undefined,
keyword: keyword.value || undefined
}
return process.list(params)
},
{
initialData: { total: 0, list: [] },
initialPageSize: 50,
total: (res: any) => res.total,
data: (res: any) => res.items,
watchingStates: [sortState, statusFilter, keyword]
}
)
</script>
<template>
<n-flex vertical :size="16">
<!-- 工具栏 -->
<n-flex :size="12">
<n-input
v-model:value="keyword"
:placeholder="$gettext('Search by PID or name')"
clearable
style="width: 250px"
>
<template #prefix>
<the-icon :size="16" icon="mdi:magnify" />
</template>
</n-input>
<n-select
v-model:value="statusFilter"
:options="statusOptions"
style="width: 150px"
@update:value="page = 1"
/>
<n-button @click="refresh" type="primary" ghost>{{ $gettext('Refresh') }}</n-button>
</n-flex>
<!-- 进程列表 -->
<n-data-table
striped
remote
virtual-scroll
:scroll-x="1400"
:loading="loading"
:columns="columns"
:data="data"
:row-key="(row: any) => row.pid"
:row-props="rowProps"
max-height="60vh"
@update:sorter="handleSorterChange"
v-model:page="page"
v-model:pageSize="pageSize"
:pagination="{
page: page,
pageCount: pageCount,
pageSize: pageSize,
itemCount: total,
showQuickJumper: true,
showSizePicker: true,
pageSizes: [50, 100, 200, 500]
}"
/>
<!-- 右键菜单 -->
<n-dropdown
placement="bottom-start"
trigger="manual"
:x="dropdownX"
:y="dropdownY"
:options="dropdownOptions"
:show="showDropdown"
:on-clickoutside="onCloseDropdown"
@select="handleDropdownSelect"
/>
<!-- 进程详情弹窗 -->
<n-modal
v-model:show="detailModal"
preset="card"
:title="$gettext('Process Details')"
style="width: 80vw; max-width: 900px"
size="huge"
:bordered="false"
:segmented="false"
>
<n-spin :show="detailLoading">
<n-descriptions v-if="processDetail" :column="2" bordered label-placement="left">
<n-descriptions-item :label="'PID'">
{{ processDetail.pid }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Parent PID')">
{{ processDetail.ppid }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Name')">
{{ processDetail.name }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('User')">
{{ processDetail.username }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Status')">
<component :is="() => renderStatus(processDetail.status)" />
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Threads')">
{{ processDetail.num_threads }}
</n-descriptions-item>
<n-descriptions-item :label="'CPU'">
{{ formatPercent(processDetail.cpu) }}%
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Memory (RSS)')">
{{ formatBytes(processDetail.rss) }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Virtual Memory')">
{{ formatBytes(processDetail.vms) }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Swap')">
{{ formatBytes(processDetail.swap) }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Disk Read')">
{{ formatBytes(processDetail.disk_read) }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Disk Write')">
{{ formatBytes(processDetail.disk_write) }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Start Time')" :span="2">
{{ formatDateTime(processDetail.start_time) }}
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Executable Path')" :span="2">
<n-ellipsis style="max-width: 600px">
{{ processDetail.exe || '-' }}
</n-ellipsis>
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Working Directory')" :span="2">
<n-ellipsis style="max-width: 600px">
{{ processDetail.cwd || '-' }}
</n-ellipsis>
</n-descriptions-item>
<n-descriptions-item :label="$gettext('Command Line')" :span="2">
<n-ellipsis :line-clamp="3" style="max-width: 600px">
{{ processDetail.cmd_line || '-' }}
</n-ellipsis>
</n-descriptions-item>
</n-descriptions>
<n-collapse v-if="processDetail" style="margin-top: 16px">
<n-collapse-item
v-if="processDetail.envs?.length"
:title="$gettext('Environment Variables')"
name="env"
>
<n-scrollbar style="max-height: 200px">
<n-log
:log="
processDetail.envs?.length
? processDetail.envs.join('\n')
: $gettext('No environment variables')
"
word-wrap
/>
</n-scrollbar>
</n-collapse-item>
<n-collapse-item
v-if="processDetail.open_files?.length"
:title="$gettext('Open Files')"
name="files"
>
<n-scrollbar style="max-height: 200px">
<n-log
:log="
processDetail.open_files?.length
? processDetail.open_files.map((f: any) => f.path).join('\n')
: $gettext('No open files')
"
word-wrap
/>
</n-scrollbar>
</n-collapse-item>
<n-collapse-item
v-if="processDetail.connections?.length"
:title="$gettext('Network Connections')"
name="connections"
>
<n-scrollbar style="max-height: 200px">
<n-log
:log="
processDetail.connections?.length
? processDetail.connections
.map(
(c: any) =>
`${c.localaddr?.ip || '*'}:${c.localaddr?.port || '*'} -> ${c.remoteaddr?.ip || '*'}:${c.remoteaddr?.port || '*'} (${c.status || 'UNKNOWN'})`
)
.join('\n')
: $gettext('No network connections')
"
word-wrap
/>
</n-scrollbar>
</n-collapse-item>
</n-collapse>
</n-spin>
</n-modal>
</n-flex>
</template>