2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 04:22:33 +08:00

feat: 进程管理增强 - 信号发送、排序筛选、搜索和右键菜单 (#1194)

* Initial plan

* feat: 实现进程管理增强功能 - 信号发送、排序筛选、搜索和右键菜单

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 修复代码审查问题并删除遗留文件 task/SystemView.vue

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
This commit is contained in:
Copilot
2026-01-09 00:47:01 +08:00
committed by GitHub
parent 7d5a0ac1c0
commit 874561a9d1
17 changed files with 646 additions and 264 deletions

View File

@@ -1,5 +1,28 @@
package request
// ProcessKill 结束进程请求
type ProcessKill struct {
PID int32 `json:"pid" validate:"required"`
}
// ProcessDetail 获取进程详情请求
type ProcessDetail struct {
PID int32 `json:"pid" form:"pid" query:"pid" validate:"required"`
}
// ProcessSignal 发送信号请求
// 支持的信号: SIGHUP(1), SIGINT(2), SIGKILL(9), SIGUSR1(10), SIGUSR2(12), SIGTERM(15), SIGCONT(18), SIGSTOP(19)
type ProcessSignal struct {
PID int32 `json:"pid" validate:"required"`
Signal int `json:"signal" validate:"required|in:1,2,9,10,12,15,18,19"`
}
// ProcessList 进程列表请求
type ProcessList struct {
Page uint `json:"page" form:"page" query:"page"`
Limit uint `json:"limit" form:"limit" query:"limit"`
Sort string `json:"sort" form:"sort" query:"sort"` // pid, name, cpu, rss, start_time
Order string `json:"order" form:"order" query:"order"` // asc, desc
Status string `json:"status" form:"status" query:"status"` // R, S, T, I, Z, W, L
Keyword string `json:"keyword" form:"keyword" query:"keyword"`
}

View File

@@ -309,7 +309,9 @@ func (route *Http) Register(r *chi.Mux) {
r.Route("/process", func(r chi.Router) {
r.Get("/", route.process.List)
r.Get("/detail", route.process.Detail)
r.Post("/kill", route.process.Kill)
r.Post("/signal", route.process.Signal)
})
r.Route("/safe", func(r chi.Router) {

View File

@@ -3,6 +3,10 @@ package service
import (
"net/http"
"slices"
"sort"
"strconv"
"strings"
"syscall"
"time"
"github.com/libtnb/chix"
@@ -20,22 +24,103 @@ func NewProcessService() *ProcessService {
}
func (s *ProcessService) List(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ProcessList](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
// 设置默认值
if req.Page == 0 {
req.Page = 1
}
if req.Limit == 0 {
req.Limit = 20
}
if req.Order == "" {
req.Order = "asc"
}
processes, err := process.Processes()
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
data := make([]types.ProcessData, 0)
data := make([]types.ProcessData, 0, len(processes))
for proc := range slices.Values(processes) {
data = append(data, s.processProcess(proc))
procData := s.processProcessBasic(proc)
// 状态筛选
if req.Status != "" && procData.Status != req.Status {
continue
}
// 关键词搜索(按 PID 或进程名)
if req.Keyword != "" {
keyword := strings.ToLower(req.Keyword)
pidStr := strconv.FormatInt(int64(procData.PID), 10)
nameMatch := strings.Contains(strings.ToLower(procData.Name), keyword)
pidMatch := strings.Contains(pidStr, keyword)
if !nameMatch && !pidMatch {
continue
}
}
data = append(data, procData)
}
paged, total := Paginate(r, data)
// 排序
if req.Sort != "" {
s.sortProcesses(data, req.Sort, req.Order)
}
// 分页 - 使用 int64 避免溢出
total := uint(len(data))
start := uint64(req.Page-1) * uint64(req.Limit)
end := uint64(req.Page) * uint64(req.Limit)
if start > uint64(total) {
data = []types.ProcessData{}
} else {
if end > uint64(total) {
end = uint64(total)
}
data = data[start:end]
}
Success(w, chix.M{
"total": total,
"items": paged,
"items": data,
})
}
// sortProcesses 对进程列表进行排序
func (s *ProcessService) sortProcesses(data []types.ProcessData, sortBy, order string) {
sort.Slice(data, func(i, j int) bool {
var less bool
switch sortBy {
case "pid":
less = data[i].PID < data[j].PID
case "name":
less = strings.ToLower(data[i].Name) < strings.ToLower(data[j].Name)
case "cpu":
less = data[i].CPU < data[j].CPU
case "rss":
less = data[i].RSS < data[j].RSS
case "start_time":
less = data[i].StartTime < data[j].StartTime
case "ppid":
less = data[i].PPID < data[j].PPID
case "num_threads":
less = data[i].NumThreads < data[j].NumThreads
default:
less = data[i].PID < data[j].PID
}
if order == "desc" {
return !less
}
return less
})
}
@@ -60,8 +145,49 @@ func (s *ProcessService) Kill(w http.ResponseWriter, r *http.Request) {
Success(w, nil)
}
// processProcess 处理进程数据
func (s *ProcessService) processProcess(proc *process.Process) types.ProcessData {
// Signal 向进程发送信号
func (s *ProcessService) Signal(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ProcessSignal](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
proc, err := process.NewProcess(req.PID)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = proc.SendSignal(syscall.Signal(req.Signal)); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
// Detail 获取进程详情
func (s *ProcessService) Detail(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ProcessDetail](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
proc, err := process.NewProcess(req.PID)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
data := s.processProcessFull(proc)
Success(w, data)
}
// processProcessBasic 处理进程基本数据(用于列表,减少数据获取)
func (s *ProcessService) processProcessBasic(proc *process.Process) types.ProcessData {
data := types.ProcessData{
PID: proc.Pid,
}
@@ -83,6 +209,18 @@ func (s *ProcessService) processProcess(proc *process.Process) types.ProcessData
}
data.NumThreads, _ = proc.NumThreads()
data.CPU, _ = proc.CPUPercent()
if mem, err := proc.MemoryInfo(); err == nil {
data.RSS = mem.RSS
}
return data
}
// processProcessFull 处理进程完整数据(用于详情)
func (s *ProcessService) processProcessFull(proc *process.Process) types.ProcessData {
data := s.processProcessBasic(proc)
// 获取更多内存信息
if mem, err := proc.MemoryInfo(); err == nil {
data.RSS = mem.RSS
data.Data = mem.Data
@@ -106,5 +244,9 @@ func (s *ProcessService) processProcess(proc *process.Process) types.ProcessData
data.OpenFiles = slices.Compact(data.OpenFiles)
data.Envs = slices.Compact(data.Envs)
// 获取可执行文件路径和工作目录
data.Exe, _ = proc.Exe()
data.Cwd, _ = proc.Cwd()
return data
}

View File

@@ -20,6 +20,8 @@ type ProcessData struct {
DiskWrite uint64 `json:"disk_write"`
CmdLine string `json:"cmd_line"`
Exe string `json:"exe"` // 可执行文件路径
Cwd string `json:"cwd"` // 工作目录
RSS uint64 `json:"rss"`
VMS uint64 `json:"vms"`

View File

@@ -1,8 +1,22 @@
import { http } from '@/utils'
export interface ProcessListParams {
page: number
limit: number
sort?: string // pid, name, cpu, rss, start_time
order?: string // asc, desc
status?: string // R, S, T, I, Z, W, L
keyword?: string
}
export default {
// 获取进程列表
list: (page: number, limit: number) => http.Get(`/process`, { params: { page, limit } }),
// 杀死进程
kill: (pid: number) => http.Post(`/process/kill`, { pid })
list: (params: ProcessListParams) =>
http.Get(`/process`, { params }),
// 获取进程详情
detail: (pid: number) => http.Get(`/process/detail`, { params: { pid } }),
// 杀死进程 (SIGKILL)
kill: (pid: number) => http.Post(`/process/kill`, { pid }),
// 向进程发送信号
signal: (pid: number, signal: number) => http.Post(`/process/signal`, { pid, signal })
}

View File

@@ -6,7 +6,7 @@ const year = new Date().getFullYear()
</script>
<template>
<footer color="#6a6a6a" f-c-c flex-col text-14>
<footer color="#6a6a6a" text-14 f-c-c flex-col >
<p>
© 2022 - {{ year }}
<a hover="decoration-primary color-primary" target="__blank" href="https://acepanel.net/">

View File

@@ -10,7 +10,7 @@ withDefaults(defineProps<Props>(), {
<template>
<transition appear mode="out-in" name="fade-slide">
<section class="cus-scroll-y wh-full flex-col bg-[#f5f6fb] p-15" dark:bg-hex-121212>
<section class="cus-scroll-y p-15 bg-[#f5f6fb] flex-col wh-full" dark:bg-hex-121212>
<slot />
<app-footer v-if="showFooter" mt-auto pt-20 />
</section>

View File

@@ -39,12 +39,12 @@ const themeStore = useThemeStore()
<header
:style="`height: ${themeStore.header.height}px`"
dark="bg-dark border-0"
flex
items-center
border-b
bg-white
px-15
bc-eee
px-15 border-b bg-white flex items-center bc-eee
>
<app-header />
</header>

View File

@@ -12,13 +12,13 @@ const themeStore = useThemeStore()
</script>
<template>
<div w-full flex items-center justify-between>
<div flex w-full items-center justify-between >
<menu-collapse v-if="themeStore.isMobile" />
<section v-if="!themeStore.isMobile && themeStore.tab.visible" w-0 flex-1 pr-12>
<section v-if="!themeStore.isMobile && themeStore.tab.visible" pr-12 flex-1 w-0 >
<app-tab />
</section>
<span v-if="!themeStore.isMobile && themeStore.tab.visible" mx-6 opacity-20>|</span>
<div ml-auto flex flex-shrink-0 items-center px-12>
<div ml-auto px-12 flex flex-shrink-0 items-center >
<reload-page />
<full-screen />
<theme-mode />

View File

@@ -5,7 +5,7 @@ import SideMenu from './components/SideMenu.vue'
</script>
<template>
<div class="h-screen flex flex-col">
<div class="flex flex-col h-screen">
<side-logo class="flex-shrink-0" />
<side-menu class="flex-shrink-0 flex-grow-1" />
<side-setting class="flex-shrink-0" />

View File

@@ -638,7 +638,7 @@ if (import.meta.hot) {
trigger="hover"
>
<template #trigger>
<n-flex vertical flex items-center p-20 pl-40 pr-40>
<n-flex vertical p-20 pl-40 pr-40 flex items-center >
<p>{{ item.path }}</p>
<n-progress
type="dashboard"
@@ -855,7 +855,7 @@ if (import.meta.hot) {
>
<n-tag>{{ $gettext('Read/Write Latency') }} {{ current.diskRWTime }}ms</n-tag>
</n-flex>
<n-card :bordered="false" h-530 pt-10>
<n-card :bordered="false" pt-10 h-530 >
<v-chart class="chart" :option="chartOptions" autoresize />
</n-card>
</n-flex>

View File

@@ -444,13 +444,13 @@ watch(data, () => {
<template>
<common-page show-header show-footer>
<template #tabbar>
<div class="flex items-center justify-between gap-8 py-4">
<div class="flex items-center gap-6">
<div class="flex items-center gap-10">
<div class="py-4 flex gap-8 items-center justify-between">
<div class="flex gap-6 items-center">
<div class="flex gap-10 items-center">
{{ $gettext('Enable Monitoring') }}
<n-switch v-model:value="monitorSwitch" @update-value="handleUpdate" />
</div>
<div class="flex items-center gap-10 pl-20">
<div class="pl-20 flex gap-10 items-center">
{{ $gettext('Save Days') }}
<n-input-number v-model:value="saveDay">
<template #suffix> {{ $gettext('days') }} </template>
@@ -461,9 +461,9 @@ watch(data, () => {
</div>
</div>
<div class="flex items-center gap-10">
<div class="flex gap-10 items-center">
<span>{{ $gettext('Time Selection') }}</span>
<div class="flex items-center gap-2">
<div class="flex gap-2 items-center">
<n-date-picker v-model:value="start" type="datetime" />
<span class="mx-1">-</span>
<n-date-picker v-model:value="end" type="datetime" />

View File

@@ -1,171 +0,0 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, NTag } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import process from '@/api/panel/process'
import { formatBytes, formatDateTime, formatPercent } from '@/utils'
const { $gettext } = useGettext()
const columns: any = [
{
title: 'PID',
key: 'pid',
width: 120,
ellipsis: { tooltip: true }
},
{
title: $gettext('Name'),
key: 'name',
minWidth: 250,
resizable: true,
ellipsis: { tooltip: true }
},
{
title: $gettext('Parent PID'),
key: 'ppid',
width: 120,
ellipsis: { tooltip: true }
},
{
title: $gettext('Threads'),
key: 'num_threads',
width: 100,
ellipsis: { tooltip: true }
},
{
title: $gettext('User'),
key: 'username',
minWidth: 100,
ellipsis: { tooltip: true }
},
{
title: $gettext('Status'),
key: 'status',
minWidth: 150,
ellipsis: { tooltip: true },
render(row: any) {
switch (row.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: () => row.status })
}
}
},
{
title: 'CPU',
key: 'cpu',
minWidth: 100,
ellipsis: { tooltip: true },
render(row: any): string {
return formatPercent(row.cpu) + '%'
}
},
{
title: $gettext('Memory'),
key: 'rss',
minWidth: 100,
ellipsis: { tooltip: true },
render(row: any): string {
return formatBytes(row.rss)
}
},
{
title: $gettext('Start Time'),
key: 'start_time',
width: 160,
ellipsis: { tooltip: true },
render(row: any): string {
return formatDateTime(row.start_time)
}
},
{
title: $gettext('Actions'),
key: 'actions',
width: 150,
hideInExcel: true,
render(row: any) {
return h(
NPopconfirm,
{
onPositiveClick: () => {
useRequest(process.kill(row.pid)).onSuccess(() => {
refresh()
window.$message.success(
$gettext('Process %{ pid } has been terminated', { pid: row.pid })
)
})
}
},
{
default: () => {
return $gettext('Are you sure you want to terminate process %{ pid }?', {
pid: row.pid
})
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'error'
},
{
default: () => $gettext('Terminate')
}
)
}
}
)
}
}
]
const { loading, data, page, total, pageSize, pageCount, refresh } = usePagination(
(page, pageSize) => process.list(page, pageSize),
{
initialData: { total: 0, list: [] },
initialPageSize: 20,
total: (res: any) => res.total,
data: (res: any) => res.items
}
)
</script>
<template>
<n-flex vertical>
<n-data-table
striped
remote
:scroll-x="1400"
:loading="loading"
:columns="columns"
:data="data"
:row-key="(row: any) => row.pid"
v-model:page="page"
v-model:pageSize="pageSize"
:pagination="{
page: page,
pageCount: pageCount,
pageSize: pageSize,
itemCount: total,
showQuickJumper: true,
showSizePicker: true,
pageSizes: [20, 50, 100, 200]
}"
/>
</n-flex>
</template>

View File

@@ -100,7 +100,7 @@ const handleTest = async () => {
</n-alert>
<n-progress v-if="inTest" :percentage="progress" processing />
</n-flex>
<n-flex vertical items-center pt-40>
<n-flex vertical pt-40 items-center >
<div w-800>
<n-grid :cols="3">
<n-gi>

View File

@@ -1,36 +1,127 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, NTag } from 'naive-ui'
import { NButton, NDataTable, NTag } from 'naive-ui'
import type { DataTableSortState, DropdownOption } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import process from '@/api/panel/process'
import process, { type ProcessListParams } from '@/api/panel/process'
import { formatBytes, formatDateTime, formatPercent } from '@/utils'
const { $gettext } = useGettext()
// 排序和筛选状态
const sortKey = ref<string>('')
const sortOrder = ref<string>('asc')
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, // 终止
SIGSTOP: 19, // 暂停
SIGCONT: 18, // 继续
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: any = [
{
title: 'PID',
key: 'pid',
width: 120,
width: 100,
sortOrder: false,
sorter: true,
ellipsis: { tooltip: true }
},
{
title: $gettext('Name'),
key: 'name',
minWidth: 250,
minWidth: 200,
resizable: true,
sortOrder: false,
sorter: true,
ellipsis: { tooltip: true }
},
{
title: $gettext('Parent PID'),
key: 'ppid',
width: 120,
width: 100,
sortOrder: false,
sorter: true,
ellipsis: { tooltip: true }
},
{
title: $gettext('Threads'),
key: 'num_threads',
width: 100,
width: 80,
sortOrder: false,
sorter: true,
ellipsis: { tooltip: true }
},
{
@@ -42,33 +133,18 @@ const columns: any = [
{
title: $gettext('Status'),
key: 'status',
minWidth: 150,
minWidth: 100,
ellipsis: { tooltip: true },
render(row: any) {
switch (row.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: () => row.status })
}
return renderStatus(row.status)
}
},
{
title: 'CPU',
key: 'cpu',
minWidth: 100,
width: 80,
sortOrder: false,
sorter: true,
ellipsis: { tooltip: true },
render(row: any): string {
return formatPercent(row.cpu) + '%'
@@ -77,7 +153,9 @@ const columns: any = [
{
title: $gettext('Memory'),
key: 'rss',
minWidth: 100,
width: 100,
sortOrder: false,
sorter: true,
ellipsis: { tooltip: true },
render(row: any): string {
return formatBytes(row.rss)
@@ -87,6 +165,8 @@ const columns: any = [
title: $gettext('Start Time'),
key: 'start_time',
width: 160,
sortOrder: false,
sorter: true,
ellipsis: { tooltip: true },
render(row: any): string {
return formatDateTime(row.start_time)
@@ -95,66 +175,232 @@ const columns: any = [
{
title: $gettext('Actions'),
key: 'actions',
width: 150,
width: 100,
hideInExcel: true,
render(row: any) {
return h(
NPopconfirm,
NButton,
{
onPositiveClick: () => {
useRequest(process.kill(row.pid)).onSuccess(() => {
refresh()
window.$message.success(
$gettext('Process %{ pid } has been terminated', { pid: row.pid })
)
})
}
size: 'small',
type: 'error',
onClick: () => handleKill(row.pid)
},
{
default: () => {
return $gettext('Are you sure you want to terminate process %{ pid }?', {
pid: row.pid
})
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'error'
},
{
default: () => $gettext('Terminate')
}
)
}
default: () => $gettext('Kill')
}
)
}
}
]
// 行属性 - 支持右键菜单
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 handleKill = (pid: number) => {
window.$dialog.warning({
title: $gettext('Confirm'),
content: $gettext('Are you sure you want to kill process %{ pid }?', { pid: pid.toString() }),
positiveText: $gettext('Confirm'),
negativeText: $gettext('Cancel'),
onPositiveClick: () => {
useRequest(process.kill(pid)).onSuccess(() => {
refresh()
window.$message.success($gettext('Process %{ pid } has been killed', { 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 (sorter && !Array.isArray(sorter)) {
sortKey.value = sorter.columnKey as string
sortOrder.value = sorter.order === 'descend' ? 'desc' : 'asc'
} else {
sortKey.value = ''
sortOrder.value = 'asc'
}
refresh()
}
// 搜索防抖
const debouncedSearch = useDebounceFn(() => {
page.value = 1
refresh()
}, 300)
// 处理搜索输入
const handleSearch = () => {
debouncedSearch()
}
// 处理状态筛选变化
const handleStatusChange = () => {
page.value = 1
refresh()
}
// 分页获取进程列表
const { loading, data, page, total, pageSize, pageCount, refresh } = usePagination(
(page, pageSize) => process.list(page, pageSize),
(page, pageSize) => {
const params: ProcessListParams = {
page,
limit: pageSize,
sort: sortKey.value || undefined,
order: sortOrder.value || undefined,
status: statusFilter.value || undefined,
keyword: keyword.value || undefined
}
return process.list(params)
},
{
initialData: { total: 0, list: [] },
initialPageSize: 20,
initialPageSize: 50,
total: (res: any) => res.total,
data: (res: any) => res.items
data: (res: any) => res.items,
watchingStates: [sortKey, sortOrder, statusFilter, keyword]
}
)
</script>
<template>
<n-flex vertical>
<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"
@input="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<n-icon :component="() => h('span', { class: 'i-mdi-magnify' })" />
</template>
</n-input>
<n-select
v-model:value="statusFilter"
:options="statusOptions"
style="width: 150px"
@update:value="handleStatusChange"
/>
<n-button @click="refresh" type="primary" ghost>{{ $gettext('Refresh') }}</n-button>
</n-flex>
<!-- 提示信息 -->
<n-alert type="info" :show-icon="false">
{{ $gettext('Right-click on a process row to send signals (like Windows Task Manager)') }}
</n-alert>
<!-- 进程列表 -->
<n-data-table
striped
remote
:scroll-x="1400"
virtual-scroll
:scroll-x="1300"
: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="{
@@ -164,8 +410,132 @@ const { loading, data, page, total, pageSize, pageCount, refresh } = usePaginati
itemCount: total,
showQuickJumper: true,
showSizePicker: true,
pageSizes: [20, 50, 100, 200]
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 :title="$gettext('Environment Variables')" name="env">
<n-scrollbar style="max-height: 200px">
<n-code
:code="processDetail.envs?.join('\n') || $gettext('No environment variables')"
language="text"
word-wrap
/>
</n-scrollbar>
</n-collapse-item>
<n-collapse-item :title="$gettext('Open Files')" name="files">
<n-scrollbar style="max-height: 200px">
<n-code
:code="
processDetail.open_files?.map((f: any) => f.path).join('\n') ||
$gettext('No open files')
"
language="text"
word-wrap
/>
</n-scrollbar>
</n-collapse-item>
<n-collapse-item :title="$gettext('Network Connections')" name="connections">
<n-scrollbar style="max-height: 200px">
<n-code
:code="
processDetail.connections
?.map(
(c: any) =>
`${c.laddr?.ip || ''}:${c.laddr?.port || ''} -> ${c.raddr?.ip || ''}:${c.raddr?.port || ''} (${c.status})`
)
.join('\n') || $gettext('No network connections')
"
language="text"
word-wrap
/>
</n-scrollbar>
</n-collapse-item>
</n-collapse>
</n-spin>
</n-modal>
</n-flex>
</template>

View File

@@ -339,7 +339,7 @@ onMounted(() => {
</n-form-item>
<n-form-item :label="$gettext('Raw Output')">
<n-switch v-model:value="createModel.raw" />
<span ml-10 text-gray>
<span text-gray ml-10 >
{{ $gettext('Return script output as raw text instead of JSON') }}
</span>
</n-form-item>
@@ -374,7 +374,7 @@ onMounted(() => {
</n-form-item>
<n-form-item :label="$gettext('Raw Output')">
<n-switch v-model:value="editModel.raw" />
<span ml-10 text-gray>
<span text-gray ml-10 >
{{ $gettext('Return script output as raw text instead of JSON') }}
</span>
</n-form-item>

View File

@@ -201,7 +201,7 @@ const hasArg = (args: string[], arg: string) => {
:on-create="onCreateListen"
>
<template #default="{ value }">
<div w-full flex items-center>
<div flex w-full items-center >
<n-input v-model:value="value.address" clearable />
<n-checkbox
:checked="hasArg(value.args, 'ssl')"