This commit is contained in:
akrike
2025-09-06 13:48:13 +08:00
parent 8fd70be906
commit ab7ef546ea
19 changed files with 720 additions and 121 deletions

View File

@@ -4,34 +4,33 @@ var CF = new(configuration)
// 只支持 float64、int、int64、bool、string类型
type configuration struct {
LogLevel string `default:"debug" describe:"日志等级[debug,info]"`
Listen string `default:":8797" describe:"监听端口"`
StorgeType string `default:"sqlite" describe:"存储引擎[sqlite、es、bleve]"`
EsUrl string `default:"" describe:"Elasticsearch url"`
EsIndex string `default:"server_log_v1" describe:"Elasticsearch index"`
EsUsername string `default:"" describe:"Elasticsearch用户名"`
EsPassword string `default:"" describe:"Elasticsearch密码"`
EsWindowLimit bool `default:"true" describe:"Es分页10000条限制"`
FileSizeLimit float64 `default:"10.0" describe:"文件大小限制MB"`
ProcessInputPrefix string `default:">" describe:"进程输入前缀"`
ProcessRestartsLimit int `default:"2" describe:"进程重启次数限制"`
ProcessMsgCacheLinesLimit int `default:"50" describe:"std进程缓存消息行数"`
ProcessMsgCacheBufLimit int `default:"4096" describe:"pty进程缓存消息字节长度"`
ProcessExpireTime int64 `default:"60" describe:"进程控制权过期时间(秒)"`
PerformanceInfoListLength int `default:"30" describe:"性能信息存储长度"`
PerformanceInfoInterval int `default:"60" describe:"监控获取间隔时间(秒)"`
TerminalConnectTimeout int `default:"10" describe:"终端连接超时时间(分钟)"`
UserPassWordMinLength int `default:"4" describe:"用户密码最小长度"`
LogMinLenth int `default:"0" describe:"过滤日志最小长度"`
LogHandlerPoolSize int `default:"10" describe:"日志处理并行数"`
PprofEnable bool `default:"true" describe:"启用pprof分析工具"`
KillWaitTime int `default:"5" describe:"kill信号等待时间"`
TaskTimeout int `default:"60" describe:"任务执行超时时间(秒)"`
TokenExpirationTime int64 `default:"720" describe:"token过期时间小时"`
WsHealthCheckInterval int `default:"3" describe:"ws主动健康检查间隔"`
CgroupPeriod int64 `default:"100000" describe:"CgroupPeriod"`
CgroupSwapLimit bool `default:"false" describe:"cgroup swap限制"`
CondWaitTime int `default:"30" describe:"长轮询等待时间(秒)"`
PerformanceCapacityDisplay bool `default:"false" describe:"性能资源容量显示"`
Tui bool `default:"-"`
LogLevel string `default:"debug" describe:"日志等级[debug,info]"`
Listen string `default:":8797" describe:"监听端口"`
StorgeType string `default:"sqlite" describe:"存储引擎[sqlite、es、bleve]"`
EsUrl string `default:"" describe:"Elasticsearch url"`
EsIndex string `default:"server_log_v1" describe:"Elasticsearch index"`
EsUsername string `default:"" describe:"Elasticsearch用户名"`
EsPassword string `default:"" describe:"Elasticsearch密码"`
EsWindowLimit bool `default:"true" describe:"Es分页10000条限制"`
FileSizeLimit float64 `default:"10.0" describe:"文件大小限制MB"`
ProcessInputPrefix string `default:">" describe:"进程输入前缀"`
ProcessRestartsLimit int `default:"2" describe:"进程重启次数限制"`
ProcessMsgCacheLinesLimit int `default:"50" describe:"std进程缓存消息行数"`
ProcessMsgCacheBufLimit int `default:"4096" describe:"pty进程缓存消息字节长度"`
ProcessExpireTime int64 `default:"60" describe:"进程控制权过期时间(秒)"`
PerformanceInfoListLength int `default:"30" describe:"性能信息存储长度"`
PerformanceInfoInterval int `default:"60" describe:"监控获取间隔时间(秒)"`
TerminalConnectTimeout int `default:"10" describe:"终端连接超时时间(分钟)"`
UserPassWordMinLength int `default:"4" describe:"用户密码最小长度"`
LogMinLenth int `default:"0" describe:"过滤日志最小长度"`
LogHandlerPoolSize int `default:"10" describe:"日志处理并行数"`
PprofEnable bool `default:"true" describe:"启用pprof分析工具"`
KillWaitTime int `default:"5" describe:"kill信号等待时间"`
TaskTimeout int `default:"60" describe:"任务执行超时时间(秒)"`
TokenExpirationTime int64 `default:"720" describe:"token过期时间小时"`
WsHealthCheckInterval int `default:"3" describe:"ws主动健康检查间隔"`
CgroupPeriod int64 `default:"100000" describe:"CgroupPeriod"`
CgroupSwapLimit bool `default:"false" describe:"cgroup swap限制"`
CondWaitTime int `default:"30" describe:"长轮询等待时间(秒)"`
Tui bool `default:"-"`
}

View File

@@ -45,12 +45,18 @@ func (p *procApi) DeleteNewProcess(ctx *gin.Context, req struct {
func (p *procApi) KillProcess(ctx *gin.Context, req struct {
Uuid int `form:"uuid" binding:"required"`
}) (err error) {
if !hasOprPermission(ctx, req.Uuid, eum.OperationStop) {
return errors.New("not permission")
}
return logic.ProcessCtlLogic.KillProcess(req.Uuid)
}
func (p *procApi) StartProcess(ctx *gin.Context, req struct {
Uuid int `form:"uuid" binding:"required"`
Uuid int `json:"uuid" binding:"required"`
}) (err error) {
if !hasOprPermission(ctx, req.Uuid, eum.OperationStart) {
return errors.New("not permission")
}
prod, err := logic.ProcessCtlLogic.GetProcess(req.Uuid)
if err != nil { // 进程不存在则创建
proConfig, err := repository.ProcessRepository.GetProcessConfigById(req.Uuid)

View File

@@ -3,6 +3,7 @@ package api
import (
"context"
"errors"
"net/http"
"strconv"
"sync"
"time"
@@ -46,9 +47,15 @@ func (w *WsConnetInstance) Cancel() {
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // 允许所有跨域请求
},
}
func (w *wsApi) WebsocketHandle(ctx *gin.Context, req model.WebsocketHandleReq) (err error) {
if !hasOprPermission(ctx, req.Uuid, eum.OperationTerminal) {
return errors.New("not permission")
}
reqUser := getUserName(ctx)
proc, err := logic.ProcessCtlLogic.GetProcess(req.Uuid)
if err != nil {

View File

@@ -7,7 +7,6 @@ import (
"sync"
"github.com/google/shlex"
"github.com/lzh-1625/go_process_manager/config"
"github.com/lzh-1625/go_process_manager/internal/app/eum"
"github.com/lzh-1625/go_process_manager/internal/app/model"
"github.com/lzh-1625/go_process_manager/internal/app/repository"
@@ -116,10 +115,8 @@ func (p *processCtlLogic) getProcessInfoList(processConfiglist []model.Process)
pi.User = process.GetUserString()
pi.Usage.Cpu = process.performanceStatus.cpu
pi.Usage.Mem = process.performanceStatus.mem
if config.CF.PerformanceCapacityDisplay {
pi.Usage.CpuCapacity = float64(runtime.NumCPU()) * 100.0
pi.Usage.MemCapacity = float64(utils.UnwarpIgnore(mem.VirtualMemory()).Total >> 10)
}
pi.Usage.CpuCapacity = float64(runtime.NumCPU()) * 100.0
pi.Usage.MemCapacity = float64(utils.UnwarpIgnore(mem.VirtualMemory()).Total >> 10)
pi.Usage.Time = process.performanceStatus.time
pi.TermType = process.Type()
pi.CgroupEnable = process.Config.cgroupEnable

View File

@@ -1,11 +1,7 @@
package middle
import (
"reflect"
"strconv"
"github.com/lzh-1625/go_process_manager/internal/app/eum"
"github.com/lzh-1625/go_process_manager/internal/app/repository"
"github.com/gin-gonic/gin"
)
@@ -20,24 +16,3 @@ func RolePermission(needPermission eum.Role) func(ctx *gin.Context) {
ctx.Next()
}
}
func OprPermission(op eum.OprPermission) func(ctx *gin.Context) {
return func(ctx *gin.Context) {
uuid, err := strconv.Atoi(ctx.Query("uuid"))
if err != nil {
rErr(ctx, -1, "Invalid parameters!", nil)
ctx.Abort()
return
}
if v, ok := ctx.Get(eum.CtxRole); !ok || v.(eum.Role) <= eum.RoleAdmin {
ctx.Next()
return
}
if !reflect.ValueOf(repository.PermissionRepository.GetPermission(ctx.GetString(eum.CtxUserName), uuid)).FieldByName(string(op)).Bool() {
rErr(ctx, -1, "Insufficient permissions; please check your access rights!", nil)
ctx.Abort()
return
}
ctx.Next()
}
}

View File

@@ -43,7 +43,7 @@ func (p *waitCond) Trigger() {
}
func (p *waitCond) WaitGetMiddel(c *gin.Context) {
reqUser := c.GetHeader("token")
reqUser := c.GetHeader("Uuid")
defer p.timeMap.Store(reqUser, p.ts)
if ts, ok := p.timeMap.Load(reqUser); !ok || ts.(int64) > p.ts {
c.Next()

View File

@@ -9,7 +9,7 @@ type Process struct {
Cwd string `gorm:"column:cwd" json:"cwd"`
AutoRestart bool `gorm:"column:auto_restart" json:"autoRestart"`
CompulsoryRestart bool `gorm:"column:compulsory_restart" json:"compulsoryRestart"`
PushIds string `gorm:"column:push_ids" json:"pushIds"`
PushIds string `gorm:"column:push_ids;type:json" json:"pushIds"`
LogReport bool `gorm:"column:log_report" json:"logReport"`
TermType eum.TerminalType `gorm:"column:term_type" json:"termType"`
CgroupEnable bool `gorm:"column:cgroup_enable" json:"cgroupEnable"`

View File

@@ -58,16 +58,16 @@ func routePathInit(r *gin.Engine) {
{
wsGroup := apiGroup.Group("/ws")
{
wsGroup.GET("", middle.OprPermission(eum.OperationTerminal), bind(api.WsApi.WebsocketHandle, Query))
wsGroup.GET("", bind(api.WsApi.WebsocketHandle, Query))
wsGroup.GET("/share", bind(api.WsApi.WebsocketShareHandle, Query))
}
processGroup := apiGroup.Group("/process")
{
processGroup.DELETE("", middle.OprPermission(eum.OperationStop), bind(api.ProcApi.KillProcess, Query))
processGroup.DELETE("", bind(api.ProcApi.KillProcess, Query))
processGroup.GET("", bind(api.ProcApi.GetProcessList, None))
processGroup.GET("/wait", middle.ProcessWaitCond.WaitGetMiddel, bind(api.ProcApi.GetProcessList, None))
processGroup.PUT("", middle.OprPermission(eum.OperationStart), bind(api.ProcApi.StartProcess, Query))
processGroup.PUT("", bind(api.ProcApi.StartProcess, Body))
processGroup.PUT("/all", bind(api.ProcApi.StartAllProcess, None))
processGroup.DELETE("/all", bind(api.ProcApi.KillAllProcess, None))
processGroup.POST("/share", middle.RolePermission(eum.RoleAdmin), bind(api.ProcApi.ProcessCreateShare, Body))

View File

@@ -16,6 +16,7 @@
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^10.11.0",
"@vueuse/integrations": "^10.11.0",
"@xterm/addon-canvas": "^0.7.0",
"@yeger/vue-masonry-wall": "^5.0.14",
"apexcharts": "^3.52.0",
"axios": "^1.7.5",
@@ -44,7 +45,10 @@
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "^3.6.14",
"webfontloader": "^1.6.28"
"webfontloader": "^1.6.28",
"xterm": "^5.3.0",
"xterm-addon-attach": "^0.9.0",
"xterm-addon-fit": "^0.8.0"
},
"devDependencies": {
"@faker-js/faker": "^8.4.1",
@@ -2810,6 +2814,22 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@xterm/addon-canvas": {
"version": "0.7.0",
"resolved": "https://registry.npmmirror.com/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
},
"node_modules/@yeger/debounce": {
"version": "2.0.13",
"resolved": "https://registry.npmmirror.com/@yeger/debounce/-/debounce-2.0.13.tgz",
@@ -7371,6 +7391,33 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
},
"node_modules/xterm-addon-attach": {
"version": "0.9.0",
"resolved": "https://registry.npmmirror.com/xterm-addon-attach/-/xterm-addon-attach-0.9.0.tgz",
"integrity": "sha512-NykWWOsobVZPPK3P9eFkItrnBK9Lw0f94uey5zhqIVB1bhswdVBfl+uziEzSOhe2h0rT9wD0wOeAYsdSXeavPw==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-attach instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"resolved": "https://registry.npmmirror.com/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.1.tgz",

View File

@@ -18,6 +18,7 @@
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^10.11.0",
"@vueuse/integrations": "^10.11.0",
"@xterm/addon-canvas": "^0.7.0",
"@yeger/vue-masonry-wall": "^5.0.14",
"apexcharts": "^3.52.0",
"axios": "^1.7.5",
@@ -46,7 +47,10 @@
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "^3.6.14",
"webfontloader": "^1.6.28"
"webfontloader": "^1.6.28",
"xterm": "^5.3.0",
"xterm-addon-attach": "^0.9.0",
"xterm-addon-fit": "^0.8.0"
},
"devDependencies": {
"@faker-js/faker": "^8.4.1",

View File

@@ -45,7 +45,6 @@ onMounted(() => {
<component :is="currentLayout" v-if="isRouterLoaded">
<router-view> </router-view>
</component>
<BackToTop />
<Snackbar />
</v-app>
</template>

View File

@@ -1,4 +1,4 @@
import { ProcessItem } from "../types/process/process";
import { ProcessConfig, ProcessItem } from "../types/process/process";
import api from "./api";
export function getProcessList() {
@@ -30,7 +30,7 @@ export function getContorl(uuid) {
}
export function getProcessConfig(uuid) {
return api.get("/process/config", { uuid }).then((res) => res);
return api.get<ProcessConfig>("/process/config", { uuid }).then((res) => res);
}
export function deleteProcessConfig(uuid) {

View File

@@ -1,3 +1,4 @@
import { PushItem } from "../types/push/push";
import api from "./api";
export function createPush(data) {
@@ -5,7 +6,7 @@ export function createPush(data) {
}
export function getPushList() {
return api.get("/push/list", undefined).then((res) => res);
return api.get<PushItem[]>("/push/list", undefined).then((res) => res);
}
export function deletePush(id) {

View File

@@ -1,6 +1,13 @@
<script setup lang="ts">
import { ProcessItem } from "~/src/types/process/process";
import { init } from "echarts";
import TerminalPty from "./TerminalPty.vue";
import { killProcess, startProcess } from "~/src/api/process";
import { useSnackbarStore } from "~/src/stores/snackbarStore";
import ProcessConfig from "./ProcessConfig.vue";
let chartInstance;
const snackbarStore = useSnackbarStore();
const initEChart = () => {
props.data.usage.cpu = (props.data.usage.cpu ?? [0, 0]).map((num) =>
parseFloat(num.toFixed(2))
@@ -20,8 +27,8 @@ const initEChart = () => {
},
animationDuration: 2000,
grid: {
left: "3%",
right: "4%",
left: "0%",
right: "0%", // 原来 4%,改成更大
bottom: "3%",
containLabel: true,
},
@@ -34,7 +41,7 @@ const initEChart = () => {
yAxis: [
{
type: "value",
name: " CPU(" + cpu + "%)",
name: "CPU(" + cpu + "%)",
min: 0, // 设置CPU的y轴最小值为10
max: props.data.usage.cpuCapacity,
minInterval: 0.1,
@@ -46,7 +53,7 @@ const initEChart = () => {
},
{
type: "value",
name: " 内存(" + mem + "MB)",
name: "内存(" + mem + "MB)",
max: parseFloat((props.data.usage.memCapacity / 1024).toFixed(2)),
axisLine: { show: false },
axisTick: { show: false },
@@ -114,18 +121,63 @@ const initEChart = () => {
});
}
}
console.log(option);
myChart.setOption(option);
chartInstance = myChart;
};
type WsHandle = {
wsConnect: () => void;
};
const terminalComponent = ref<WsHandle | null>(null);
type ConfigHandle = {
openConfigDialog: () => void;
test: () => void;
};
const processConfigComponent = ref<ConfigHandle | null>(null);
const buttons = [
{ label: "按钮1", action: () => console.log("按钮1点击") },
{ label: "按钮2", action: () => console.log("按钮2点击") },
{ label: "按钮3", action: () => console.log("按钮3点击") },
{
icon: "mdi-console",
action: () => {
terminalComponent.value?.wsConnect();
},
},
{
icon: "mdi-play",
action: () => {
startProcess(props.data.uuid).then((e) => {
if (e.code === 0) {
snackbarStore.showSuccessMessage("success");
}
});
},
},
{
icon: "mdi-stop",
action: () => {
killProcess(props.data.uuid).then((e) => {
if (e.code === 0) {
snackbarStore.showSuccessMessage("success");
}
});
},
},
{
icon: "mdi-pencil",
action: () => {
processConfigComponent.value?.openConfigDialog();
},
},
];
const handleResize = () => {
chartInstance.resize();
};
onMounted(() => {
initEChart();
window.addEventListener("resize", handleResize);
});
const props = defineProps<{
@@ -137,9 +189,53 @@ const props = defineProps<{
<div class="chart-container">
<!-- 顶部进程名字 + 菜单 -->
<div class="header">
<div class="top-left">{{ props.data.name }}</div>
<div class="top-left">
<v-icon
color="green"
v-if="props.data.state.state == 3 || props.data.state.state == 1"
x-large
style="float: left"
>
mdi-checkbox-marked-circle</v-icon
>
<v-icon
color="red"
v-if="props.data.state.state == 0"
x-large
style="float: left"
>
mdi-stop-circle</v-icon
>
<div v-if="props.data.state.state == 2" style="float: left">
<v-tooltip top color="warning">
<template>
<v-icon color="yellow accent-4" x-large> mdi-alert-circle</v-icon>
</template>
<span>{{ props.data.state.info }}</span>
</v-tooltip>
</div>
{{ props.data.name }}
</div>
<div class="top-right">
<button @click="">菜单</button>
<v-menu bottom left>
<template v-slot:activator="{ props }">
<v-btn
variant="text"
@click=""
density="compact"
class="px-1 min-w-0"
v-bind="props"
>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list nav dense>
<v-list-item @click=""> 获取控制权 </v-list-item>
<v-list-item @click=""> 删除进程 </v-list-item>
<v-list-item @click=""> 创建分享链接 </v-list-item>
</v-list>
</v-menu>
</div>
</div>
@@ -149,12 +245,36 @@ const props = defineProps<{
<!-- 底部按钮组 + 时间 -->
<div class="footer">
<div class="bottom-left">
<button v-for="(btn, idx) in buttons" :key="idx" @click="">
{{ btn.label }}
</button>
<v-chip
size="small"
variant="outlined"
style="border-color: grey; color: black"
class="d-flex align-center"
>
<v-btn
v-for="(btn, idx) in buttons"
:key="idx"
@click="btn.action"
size="small"
:icon="btn.icon"
variant="text"
density="comfortable"
/>
</v-chip>
</div>
<div class="bottom-right text-caption">
<span>{{ props.data.startTime }}</span>
</div>
<div class="bottom-right">{{ props.data.startTime }}</div>
</div>
<TerminalPty
v-if="props.data.termType == 'pty'"
:data="props.data"
ref="terminalComponent"
></TerminalPty>
<TerminalPty v-else :data="props.data"></TerminalPty>
<ProcessConfig :data="props.data" ref="processConfigComponent"></ProcessConfig>
</div>
</template>
@@ -163,10 +283,8 @@ const props = defineProps<{
display: flex;
flex-direction: column;
width: 100%;
height: 260px; /* 可根据实际容器调整 */
height: 250px; /* 可根据实际容器调整 */
background: #fff;
border: 1px solid #ccc;
border-radius: 8px;
overflow: hidden;
}
@@ -175,7 +293,7 @@ const props = defineProps<{
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 10px;
padding: 5px 5px;
font-weight: bold;
height: 30px; /* 顶部固定高度 */
}
@@ -183,7 +301,9 @@ const props = defineProps<{
/* 中间图表自适应 */
.chart {
flex: 1; /* 占满剩余空间 */
width: 100%;
width: 90%;
margin-left: 5%;
margin-right: 5%;
}
/* 底部 footer */

View File

@@ -0,0 +1,224 @@
<script setup lang="ts">
import { ref } from "vue";
import { getProcessConfig, putProcessConfig } from "~/src/api/process";
import { getPushList } from "~/src/api/push";
import { useSnackbarStore } from "~/src/stores/snackbarStore";
import { ProcessConfig, ProcessItem } from "~/src/types/process/process";
const snackbarStore = useSnackbarStore();
const dialog = ref(false);
const configForm = ref<Partial<ProcessConfig>>({});
const pushItems = ref<{ value: any; label: string }[]>([]);
const pushSelectedValues = ref([]);
watch(
pushSelectedValues,
(newValues) => {
configForm.value.pushIds = JSON.stringify(newValues);
},
{ deep: true }
);
const props = defineProps<{
data: ProcessItem;
}>();
defineExpose({
openConfigDialog: () => {
getConfig();
initPushItem();
dialog.value = true;
},
});
const getConfig = () => {
getProcessConfig(props.data.uuid).then((e) => {
// 使用 Object.assign 来更新响应式对象,而不是替换它
if (e.data) {
Object.assign(configForm.value, e.data);
pushSelectedValues.value = JSON.parse(
(e.data!.pushIds as string) == "" ? "[]" : (e.data!.pushIds as string)
);
}
});
};
const updateJsonString = () => {
configForm.value.pushIds = JSON.stringify(pushSelectedValues);
};
const editConfig = () => {
putProcessConfig(configForm.value).then((e) => {
if (e.code === 0) {
snackbarStore.showSuccessMessage("sucess");
dialog.value = false; // 成功后通常会关闭对话框
}
});
};
const initPushItem = () => {
getPushList().then((resp) => {
// 3. 更新 ref 的 .value
if (resp.data) {
pushItems.value = resp.data.map((e) => ({
value: e.id,
label: `${e.remark} [${e.id}]`,
}));
}
});
};
</script>
<template>
<v-dialog v-model="dialog" width="700">
<v-card>
<v-card-title class="text-h5 grey lighten-2">
<v-icon left>mdi-cog</v-icon>
设置
</v-card-title>
<v-card-text>
<v-container>
<v-row>
<v-col cols="12" md="6">
<v-text-field
label="进程名称"
v-model="configForm.name"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
label="工作目录"
v-model="configForm.cwd"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
label="启动命令"
v-model="configForm.cmd"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-select
label="终端类型"
disabled
v-model="configForm.termType"
:items="['pty', 'std']"
variant="outlined"
density="compact"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="pushSelectedValues"
@change="updateJsonString"
:items="pushItems"
item-title="label"
item-value="value"
chips
label="状态推送"
multiple
variant="outlined"
density="compact"
></v-select>
</v-col>
</v-row>
<v-divider class="my-4"></v-divider>
<v-row align="center">
<v-col cols="12" sm="3">
<v-switch
:disabled="props.data.state?.state === 1"
v-model="configForm.cgroupEnable"
label="资源限制"
color="primary"
hide-details
></v-switch>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
:disabled="
!configForm.cgroupEnable || props.data.state?.state === 3
"
label="CPU 限制 (%)"
type="number"
v-model.number="configForm.cpuLimit"
variant="outlined"
density="compact"
hide-details="auto"
></v-text-field>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
:disabled="
!configForm.cgroupEnable || props.data.state?.state === 3
"
label="内存限制 (MB)"
type="number"
v-model.number="configForm.memoryLimit"
variant="outlined"
density="compact"
hide-details="auto"
></v-text-field>
</v-col>
</v-row>
<v-divider class="my-4"></v-divider>
<v-row>
<v-col cols="12" sm="4">
<v-switch
v-model="configForm.autoRestart"
label="自动重启"
color="primary"
hide-details
></v-switch>
</v-col>
<v-col cols="12" sm="4">
<v-switch
:disabled="!configForm.autoRestart"
v-model="configForm.compulsoryRestart"
label="强制重启"
color="primary"
hide-details
></v-switch>
</v-col>
<v-col cols="12" sm="4">
<v-switch
v-model="configForm.logReport"
label="日志上报"
color="primary"
hide-details
></v-switch>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" color="grey-darken-1" @click="dialog = false">
<v-icon left>mdi-close</v-icon>
取消
</v-btn>
<v-btn variant="flat" color="primary" @click="editConfig">
<v-icon left>mdi-check</v-icon>
确认
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import { ref, watch, nextTick, onUnmounted } from "vue"; // 引入 watch, nextTick 和 onUnmounted
import { useSnackbarStore } from "~/src/stores/snackbarStore";
import { ProcessItem } from "~/src/types/process/process";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { AttachAddon } from "xterm-addon-attach";
import { CanvasAddon } from '@xterm/addon-canvas';
import 'xterm/css/xterm.css';
const snackbarStore = useSnackbarStore();
const dialog = ref(false);
const props = defineProps<{
data: ProcessItem;
}>();
const xtermEl = ref<HTMLElement | null>(null);
let socket: WebSocket | null = null;
let term: Terminal | null = null;
const fitAddon = new FitAddon();
defineExpose({
wsConnect: () => {
dialog.value = true;
},
});
// 使用 watch 监听 dialog 的状态变化
watch(dialog, (newValue) => {
if (newValue) {
nextTick(() => {
initWebSocketPty();
});
}
});
const initWebSocketPty = () => {
if (!xtermEl.value) {
snackbarStore.showErrorMessage("终端容器初始化失败");
return;
}
// 在这里计算初始尺寸更准确
const initialCols = Math.floor(xtermEl.value.clientWidth / 9);
const initialRows = Math.floor(xtermEl.value.clientHeight / 19);
const baseUrl = `ws://${window.location.hostname}:8797/api/ws`;
const url = `${baseUrl}?uuid=${props.data.uuid}&token=${localStorage.getItem("token")}&cols=${initialCols}&rows=${initialRows}`;
initSocket(url);
};
const initSocket = (url: string) => {
socket = new WebSocket(url);
socket.onopen = () => {
// WebSocket 连接成功后,初始化 Terminal
initTerm();
};
socket.onclose = () => {
snackbarStore.showErrorMessage("终端连接断开");
};
socket.onerror = (err) => {
snackbarStore.showErrorMessage("终端连接发生错误");
console.error("WebSocket Error:", err);
};
};
const initTerm = () => {
if (!socket || !xtermEl.value) return;
term = new Terminal({
// rendererType: "canvas", // 已通过插件方式加载,此处无需设置
convertEol: true,
disableStdin: false,
cursorBlink: true,
theme: {
foreground: "#ECECEC",
cursor: "help",
},
});
const attachAddon = new AttachAddon(socket);
term.loadAddon(new CanvasAddon()); // 推荐先加载渲染器
term.loadAddon(attachAddon);
term.loadAddon(fitAddon);
term.open(xtermEl.value);
// 在打开后执行 fit() 来适配尺寸
fitAddon.fit();
term.focus();
window.addEventListener("resize", handleResize);
};
const handleResize = () => {
fitAddon.fit();
};
const wsClose = () => {
dialog.value = false;
cleanup();
};
const cleanup = () => {
window.removeEventListener("resize", handleResize);
if (term) {
term.dispose();
term = null;
}
if (socket) {
socket.close();
socket = null;
}
};
onUnmounted(() => {
cleanup();
});
</script>
<template>
<v-dialog
fullscreen
hide-overlay
transition="dialog-bottom-transition"
v-model="dialog"
@update:modelValue="val => !val && cleanup()"
>
<v-card
style="
height: 100%;
background-color: black;
display: flex;
flex-direction: column;
"
>
<v-toolbar
dense
dark
color="blue-grey darken-4"
style="height: 35px; flex-grow: 0"
>
<v-toolbar-title style="height: 100%"
>{{ props.data.name }} ({{ props.data.termType }})</v-toolbar-title
>
<v-spacer></v-spacer>
<v-toolbar-items style="height: 35px">
<v-btn icon dense dark @click="wsClose">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar-items>
</v-toolbar>
<div id="xterm" ref="xtermEl" style="flex-grow: 1; height: 100%; width: 100%;"></div>
</v-card>
</v-dialog>
</template>
<style>
#xterm .terminal {
height: 100%;
}
</style>

View File

@@ -1,34 +1,49 @@
export interface ProcessItem {
name: string;
uuid: number;
startTime: Date;
user: string;
usage: Usage;
state: State;
termType: TermType;
cgroupEnable: boolean;
memoryLimit: number | null;
cpuLimit: number | null;
name: string;
uuid: number;
startTime: Date;
user: string;
usage: Usage;
state: State;
termType: TermType;
cgroupEnable: boolean;
memoryLimit: number | null;
cpuLimit: number | null;
}
export interface State {
state: number;
info: Info;
state: number;
info: Info;
}
export enum Info {
Empty = "",
= "重启次数异常",
Empty = "",
= "重启次数异常",
}
export enum TermType {
Pty = "pty",
Pty = "pty",
}
export interface Usage {
cpuCapacity: number;
memCapacity: number;
cpu: number[] | null;
mem: number[] | null;
time: string[] | null;
cpuCapacity: number;
memCapacity: number;
cpu: number[] | null;
mem: number[] | null;
time: string[] | null;
}
export interface ProcessConfig {
uuid: number;
name: string;
cmd: string;
cwd: string;
autoRestart: boolean;
compulsoryRestart: boolean;
pushIds: number[] | string;
logReport: boolean;
termType: string;
cgroupEnable: boolean;
memoryLimit: null;
cpuLimit: null;
}

View File

@@ -0,0 +1,8 @@
export interface PushItem {
id: number;
method: string;
url: string;
body: string;
remark: string;
enable: boolean;
}

View File

@@ -10,18 +10,41 @@
<script setup lang="ts">
import ProcessCard from "@/components/process/ProcessCard.vue";
import axios from "axios";
import { getProcessList } from "~/src/api/process";
import { ProcessItem } from "~/src/types/process/process";
const processData = ref<ProcessItem[]>();
const uuid: string = crypto.randomUUID();
const initProcessData = () => {
getProcessList().then((e) => {
processData.value = e.data!;
console.log(e.data);
processData.value = e.data!.sort((a, b) => a.name.localeCompare(b.name));
getProcessListWait();
});
};
var cancelTokenSource;
const getProcessListWait = () => {
cancelTokenSource = axios.CancelToken.source();
axios
.get("api/process/wait", {
cancelToken: cancelTokenSource.token,
headers: {
Authorization: "bearer " + localStorage.getItem("token"),
Uuid: uuid,
},
})
.then((response) => {
processData.value = response.data.data.sort((a, b) =>
a.name.localeCompare(b.name)
);
getProcessListWait();
})
.catch((error) => {
console.error("请求错误:", error);
});
};
onMounted(() => {
initProcessData();
});
@@ -32,17 +55,24 @@ onMounted(() => {
display: flex;
flex-wrap: wrap; /* 自动换行 */
justify-content: space-between; /* 两边与中间间距均匀 */
gap: 50px; /* 每个 div 之间的间距 */
gap: 80px; /* 每个 div 之间的间距 */
}
.responsive-box {
flex: 1 1 300px; /* 最小宽度 500px */
min-width: 300px; /* 强制最小宽度 */
max-width: 100%; /* 不超过容器宽度 */
background: #f5f5f5;
padding: 16px;
border-radius: 12px;
flex: 1 1 300px; /* 最小宽度 300px */
min-width: 300px;
max-width: 100%;
background: #ffffff; /* 改为白色背景 */
border-radius: 16px; /* 圆角 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); /* 柔和阴影 */
text-align: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
padding: 10px; /* 内边距,让内容不贴边 */
transition: transform 0.2s ease, box-shadow 0.2s ease; /* 交互动画 */
}
/* 悬停效果 */
.responsive-box:hover {
transform: translateY(-6px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
}
</style>