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:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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/">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')"
|
||||
|
||||
Reference in New Issue
Block a user