6 Commits

Author SHA1 Message Date
akrike
6d0239fe60 optimal task add dialog 2025-09-18 21:08:43 +08:00
akrike
09ae980ab5 add task edit dialog 2025-09-15 21:01:09 +08:00
akrike
c52f7e2097 add task ui 2025-09-11 22:36:58 +08:00
akrike
1844a843eb add task ui 2025-09-07 21:15:47 +08:00
akrike
593185a431 update 2025-09-07 11:10:09 +08:00
akrike
ab7ef546ea update 2025-09-06 13:48:13 +08:00
35 changed files with 1618 additions and 159 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 {
@@ -64,7 +71,7 @@ func (w *wsApi) WebsocketHandle(ctx *gin.Context, req model.WebsocketHandleReq)
if err != nil {
return err
}
defer conn.Close()
log.Logger.Infow("ws连接成功")
wsCtx, cancel := context.WithCancel(context.Background())
@@ -72,8 +79,10 @@ func (w *wsApi) WebsocketHandle(ctx *gin.Context, req model.WebsocketHandleReq)
WsConnect: conn,
CancelFunc: cancel,
wsLock: sync.Mutex{},
}
if err := proc.ReadCache(wci); err != nil {
return nil
}
proc.ReadCache(wci)
if proc.State.State == eum.ProcessStateRunning {
proc.SetTerminalSize(req.Cols, req.Rows)
w.startWsConnect(wci, cancel, proc, hasOprPermission(ctx, req.Uuid, eum.OperationTerminalWrite))
@@ -92,7 +101,6 @@ func (w *wsApi) WebsocketHandle(ctx *gin.Context, req model.WebsocketHandleReq)
case <-wsCtx.Done():
log.Logger.Infow("ws连接断开", "操作类型", "tcp连接建立已被关闭")
}
conn.Close()
return
}
@@ -122,7 +130,7 @@ func (w *wsApi) WebsocketShareHandle(ctx *gin.Context, req model.WebsocketHandle
if err != nil {
return err
}
defer conn.Close()
log.Logger.Infow("ws连接成功")
data.UpdatedAt = time.Now()
repository.WsShare.Edit(data)
@@ -134,7 +142,9 @@ func (w *wsApi) WebsocketShareHandle(ctx *gin.Context, req model.WebsocketHandle
CancelFunc: cancel,
wsLock: sync.Mutex{},
}
proc.ReadCache(wci)
if err := proc.ReadCache(wci); err != nil {
return nil
}
w.startWsConnect(wci, cancel, proc, data.Write)
proc.AddConn(guestName, wci)
defer proc.DeleteConn(guestName)
@@ -152,7 +162,6 @@ func (w *wsApi) WebsocketShareHandle(ctx *gin.Context, req model.WebsocketHandle
case <-time.After(time.Until(data.ExpireTime)):
log.Logger.Infow("ws连接断开", "操作类型", "分享时间已结束")
}
conn.Close()
return
}

View File

@@ -21,7 +21,7 @@ import (
)
type Process interface {
ReadCache(ConnectInstance)
ReadCache(ConnectInstance) error
Write(string) error
WriteBytes([]byte) error
readInit()

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

@@ -2,6 +2,7 @@ package logic
import (
"bytes"
"errors"
"os"
"os/exec"
"strings"
@@ -113,10 +114,12 @@ func (p *ProcessPty) readInit() {
}
}
func (p *ProcessPty) ReadCache(ws ConnectInstance) {
if p.cacheBytesBuf != nil {
ws.Write(p.cacheBytesBuf.Bytes())
func (p *ProcessPty) ReadCache(ws ConnectInstance) error {
if p.cacheBytesBuf == nil {
return errors.New("cache is null")
}
ws.Write(p.cacheBytesBuf.Bytes())
return nil
}
func (p *ProcessPty) bufHanle(b []byte) {

View File

@@ -122,8 +122,12 @@ func (p *ProcessPty) readInit() {
}
}
func (p *ProcessPty) ReadCache(ws ConnectInstance) {
func (p *ProcessPty) ReadCache(ws ConnectInstance) error {
if p.cacheBytesBuf == nil {
return errors.New("cache is null")
}
ws.Write(p.cacheBytesBuf.Bytes())
return nil
}
func (p *ProcessPty) bufHanle(b []byte) {

View File

@@ -87,10 +87,14 @@ func (p *ProcessStd) doOnInit() {
p.cacheLine = make([]string, config.CF.ProcessMsgCacheLinesLimit)
}
func (p *ProcessStd) ReadCache(ws ConnectInstance) {
func (p *ProcessStd) ReadCache(ws ConnectInstance) error {
if len(p.cacheLine) == 0 {
return errors.New("cache is null")
}
for _, line := range p.cacheLine {
ws.WriteString(line)
}
return nil
}
func (p *ProcessStd) doOnKilled() {

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

@@ -41,7 +41,7 @@ func InitDb() {
}
sqlDB.SetConnMaxLifetime(time.Hour)
db = gdb.Session(&defaultConfig)
// db = db.Debug()
db = db.Debug()
db.AutoMigrate(
&model.Process{},
&model.User{},

View File

@@ -26,7 +26,7 @@ func (t *taskRepository) GetTaskByKey(key string) (result model.Task, err error)
}
func (t *taskRepository) AddTask(data model.Task) (taskId int, err error) {
err = db.Create(&data).Error
err = query.Task.Create(&data)
taskId = data.Id
return
}

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

@@ -4,7 +4,6 @@ import LandingLayout from "@/layouts/LandingLayout.vue";
import DefaultLayout from "@/layouts/DefaultLayout.vue";
import AuthLayout from "@/layouts/AuthLayout.vue";
import BackToTop from "@/components/common/BackToTop.vue";
import Snackbar from "@/components/common/Snackbar.vue";
import { useAppStore } from "@/stores/appStore";
import { useTheme } from "vuetify";
@@ -45,7 +44,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() {
@@ -9,12 +9,12 @@ export function getProcessListWait() {
return api.get<ProcessItem[]>("/process/wait", undefined).then((res) => res);
}
export function killProcessAll(uuid) {
return api.delete("/process/all", { uuid }).then((res) => res);
export function killProcessAll() {
return api.delete("/process/all", { }).then((res) => res);
}
export function startProcessAll(uuid) {
return api.put("/process/all", { uuid }).then((res) => res);
export function startProcessAll() {
return api.put("/process/all", { }).then((res) => res);
}
export function killProcess(uuid) {
@@ -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,7 +1,8 @@
import { TaskItem } from "../types/tassk/task";
import api from "./api";
export function getTaskAll() {
return api.get("/task/all", undefined).then((res) => res);
return api.get<TaskItem[]>("/task/all", undefined).then((res) => res);
}
export function getTaskAllWait() {
@@ -9,7 +10,7 @@ export function getTaskAllWait() {
}
export function getTaskById(id) {
return api.get("/task", { id }).then((res) => res);
return api.get<TaskItem>("/task", { id }).then((res) => res);
}
export function startTaskById(id) {

View File

@@ -0,0 +1,45 @@
<template>
<div>
<!-- 触发按钮 -->
<v-btn :color="color" size="small" variant="tonal" @click="dialog = true">
<slot>{{ label }}</slot>
</v-btn>
<!-- 确认弹窗 -->
<v-dialog v-model="dialog" max-width="400">
<v-card>
<v-card-title class="text-h6">{{ title }}</v-card-title>
<v-card-text>
{{ message }}
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="dialog = false">取消</v-btn>
<v-btn :color="color" variant="flat" @click="confirm"> 确认 </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref } from "vue";
const props = defineProps({
label: { type: String, default: "操作" },
title: { type: String, default: "确认" },
message: { type: String, default: "确定要执行此操作吗?" },
color: { type: String, default: "primary" },
});
const emits = defineEmits(["confirm"]);
const dialog = ref(false);
const confirm = () => {
dialog.value = false;
emits("confirm");
};
</script>

View File

@@ -1,6 +1,18 @@
<script setup lang="ts">
import { ProcessItem } from "~/src/types/process/process";
import { init } from "echarts";
import TerminalPty from "./TerminalPty.vue";
import {
deleteProcessConfig,
getContorl,
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))
@@ -10,7 +22,7 @@ const initEChart = () => {
);
const cpu = props.data.usage.cpu[props.data.usage.cpu.length - 1] ?? "-";
const mem = props.data.usage.mem[props.data.usage.mem.length - 1] ?? "-";
var myChart = init(document.getElementById("echarts" + props.index));
var myChart = init(document.getElementById("echarts" + props.data.uuid));
var option = {
tooltip: {
trigger: "axis",
@@ -20,8 +32,8 @@ const initEChart = () => {
},
animationDuration: 2000,
grid: {
left: "3%",
right: "4%",
left: "0%",
right: "0%", // 原来 4%,改成更大
bottom: "3%",
containLabel: true,
},
@@ -34,7 +46,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 +58,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 },
@@ -88,7 +100,7 @@ const initEChart = () => {
name: "CPU限制%",
type: "line",
yAxisIndex: 0,
data: new Array(props.data.usage.time!.length).fill(
data: new Array(props.data.usage.time?.length ?? 0).fill(
props.data.cpuLimit
),
lineStyle: {
@@ -103,7 +115,7 @@ const initEChart = () => {
name: "内存限制MB",
type: "line",
yAxisIndex: 1,
data: new Array(props.data.usage.time!.length).fill(
data: new Array(props.data.usage.time?.length ?? 0).fill(
props.data.memoryLimit
),
lineStyle: {
@@ -114,47 +126,177 @@ const initEChart = () => {
});
}
}
console.log(option);
myChart.setOption(option);
chartInstance = myChart;
};
type WsHandle = {
wsConnect: () => void;
};
const terminalComponent = ref<WsHandle | null>(null);
type ConfigHandle = {
openConfigDialog: () => 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<{
data: ProcessItem;
index: Number;
}>();
const control = () => {
getContorl(props.data.uuid).then((e) => {
if (e.code === 0) {
snackbarStore.showSuccessMessage("sucess");
}
});
};
const del = () => {
deleteProcessConfig(props.data.uuid).then((e) => {
if (e.code === 0) {
snackbarStore.showSuccessMessage("sucess");
}
});
};
</script>
<template>
<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="control"> 获取控制权 </v-list-item>
<v-list-item @click="del"> 删除进程 </v-list-item>
<v-list-item @click=""> 创建分享链接 </v-list-item>
</v-list>
</v-menu>
</div>
</div>
<!-- 中间ECharts -->
<div :id="'echarts' + props.index" class="chart"></div>
<div :id="'echarts' + props.data.uuid" class="chart"></div>
<!-- 底部按钮组 + 时间 -->
<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 +305,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 +315,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 +323,9 @@ const props = defineProps<{
/* 中间图表自适应 */
.chart {
flex: 1; /* 占满剩余空间 */
width: 100%;
width: 90%;
margin-left: 5%;
margin-right: 5%;
}
/* 底部 footer */

View File

@@ -0,0 +1,220 @@
<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-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-row>
<v-divider class="my-4"></v-divider>
<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-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,197 @@
<script setup lang="ts">
import { ref } from "vue";
import { postProcessConfig } from "~/src/api/process";
import { getPushList } from "~/src/api/push";
import { useSnackbarStore } from "~/src/stores/snackbarStore";
import { ProcessConfig } 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 }
);
defineExpose({
createProcessDialog: () => {
initPushItem();
dialog.value = true;
},
});
const updateJsonString = () => {
configForm.value.pushIds = JSON.stringify(pushSelectedValues);
};
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}]`,
}));
}
});
};
const create = () => {
postProcessConfig(configForm.value).then((e) => {
if (e.code === 0) {
snackbarStore.showSuccessMessage("sucess");
dialog.value = false;
}
});
};
</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-select
label="终端类型"
v-model="configForm.termType"
:items="['pty', 'std']"
variant="outlined"
density="compact"
></v-select>
</v-col>
<v-col cols="12" md="12">
<v-text-field
label="工作目录"
v-model="configForm.cwd"
variant="outlined"
density="compact"
></v-text-field>
</v-col>
<v-col cols="12" md="12">
<v-textarea
label="启动命令"
rows="2"
v-model="configForm.cmd"
variant="outlined"
density="compact"
></v-textarea>
</v-col>
</v-row>
<v-divider class="my-4"></v-divider>
<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-divider class="my-4"></v-divider>
<v-row align="center">
<v-col cols="12" sm="3">
<v-switch
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"
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"
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="create">
<v-icon left>mdi-check</v-icon>
确认
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View File

@@ -0,0 +1,180 @@
<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("终端连接断开");
dialog.value = false;
};
socket.onerror = (err) => {
snackbarStore.showErrorMessage("终端连接发生错误");
console.error("WebSocket Error:", err);
};
};
const initTerm = () => {
if (!socket || !xtermEl.value) return;
const showCursor = props.data.state.state === 3;
term = new Terminal({
convertEol: true,
disableStdin: false,
cursorBlink: showCursor,
cursorStyle: "block",
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 toolbarColor = computed(() => {
if (props.data.state.state == 3) {
return;
}
return "red";
});
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
:color="toolbarColor"
dark
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

@@ -0,0 +1,8 @@
export default [
{
icon: "mdi-application-braces",
name: "task-page",
key: "menu.task",
link: "/task",
},
];

View File

@@ -5,8 +5,8 @@ import menuCharts from "./menus/charts.menu";
import menuUML from "./menus/uml.menu";
import menuLanding from "./menus/landing.menu";
import menuData from "./menus/data.menu";
import menuAi from "./menus/ai.menu";
import menuProcess from "./menus/process.menus"
import menuProcess from "./menus/process.menus";
import menuTask from "./menus/task.menus";
export default {
menu: [
@@ -26,6 +26,10 @@ export default {
text: "process",
items: menuProcess,
},
{
text: "task",
items: menuTask,
},
{
text: "Apps",
items: menuApps,

View File

@@ -14,7 +14,6 @@ import VueVirtualScroller from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import VueApexCharts from "vue3-apexcharts";
import piniaPersist from "pinia-plugin-persist";
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar';
import 'vue3-perfect-scrollbar/style.css';
import "@/styles/main.scss";
import router from "./router";
@@ -31,7 +30,6 @@ const app = createApp(App);
app.config.globalProperties.$echarts = echarts;
app.directive('permission', permission);
app.use(router);
app.use(PerfectScrollbarPlugin);
app.use(MasonryWall);
app.use(VueVirtualScroller);
app.use(VueApexCharts);

View File

@@ -10,6 +10,7 @@ import UmlRoutes from "./uml.routes";
import AppsRoutes from "./apps.routes";
import DataRoutes from "./data.routes";
import ProcessRoutes from "./process.routes";
import TaskRoutes from "./task.routes";
export const routes = [
{
@@ -41,7 +42,8 @@ export const routes = [
...UmlRoutes,
...AppsRoutes,
...DataRoutes,
...ProcessRoutes
...ProcessRoutes,
...TaskRoutes
];
// 动态路由,基于用户权限动态去加载

View File

@@ -0,0 +1,12 @@
// users Data Page
export default [
{
path: "/task",
component: () => import("@/views/task/Task.vue"),
meta: {
requiresAuth: true,
layout: "landing",
category: "Data",
},
},
];

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

@@ -0,0 +1,20 @@
export interface TaskItem {
id: number;
name: string;
processId: number;
condition: number;
nextId: null;
operation: number;
triggerEvent: null;
triggerTarget: null;
operationTarget: number;
cron: string;
enable: boolean;
apiEnable: boolean;
key: string;
processName: string;
targetName: string;
triggerName: string;
startTime: Date;
running: boolean;
}

View File

@@ -1,48 +1,139 @@
<template>
<!-- <div class="toolbar">
<ConfirmButton @confirm="startAll" color="#3CB371">全部启动</ConfirmButton>
<ConfirmButton @confirm="killAll" color="#CD5555">全部停止</ConfirmButton>
<v-btn
size="small"
variant="tonal"
color="blue"
@click="processCreateComponent?.createProcessDialog()"
>创建<v-icon dark right> mdi-plus-circle </v-icon>
</v-btn>
</div> -->
<v-container>
<!-- 顶部工具栏 -->
<!-- 主体网格 -->
<div class="flex-grid">
<div v-for="(i, v) in processData" class="responsive-box">
<ProcessCard :data="i" :index="v"></ProcessCard>
<div v-for="(i, v) in processData" :key="i.uuid" class="responsive-box">
<ProcessCard :data="i" :index="v" />
</div>
</div>
</v-container>
<ProcessCreate ref="processCreateComponent"></ProcessCreate>
</template>
<script setup lang="ts">
import ProcessCard from "@/components/process/ProcessCard.vue";
import { getProcessList } from "~/src/api/process";
import axios from "axios";
import {
getProcessList,
killProcessAll,
startProcessAll,
} from "~/src/api/process";
import ProcessCreate from "~/src/components/process/ProcessCreate.vue";
import { useSnackbarStore } from "~/src/stores/snackbarStore";
import { ProcessItem } from "~/src/types/process/process";
type CreateHandle = {
createProcessDialog: () => void;
test: () => void;
};
const processCreateComponent = ref<CreateHandle | null>(null);
const processData = ref<ProcessItem[]>();
const uuid: string = crypto.randomUUID();
const snackbarStore = useSnackbarStore();
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();
});
};
const startAll = () => {
startProcessAll().then((e) => {
if (e.code === 0) {
snackbarStore.showSuccessMessage("sucess");
}
});
};
const killAll = () => {
killProcessAll().then((e) => {
if (e.code === 0) {
snackbarStore.showSuccessMessage("sucess");
}
});
};
let cancelTokenSource: any;
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();
});
</script>
<style scoped>
/* 工具栏样式 */
.toolbar {
display: flex;
justify-content: flex-end; /* 靠右对齐 */
gap: 10px;
margin-bottom: 20px;
padding: 4px 0;
}
/* 原来的网格样式 */
.flex-grid {
display: flex;
flex-wrap: wrap; /* 自动换行 */
justify-content: space-between; /* 两边与中间间距均匀 */
gap: 50px; /* 每个 div 之间的间距 */
flex-wrap: wrap;
justify-content: space-between;
gap: 80px;
}
.responsive-box {
flex: 1 1 300px; /* 最小宽度 500px */
min-width: 300px; /* 强制最小宽度 */
max-width: 100%; /* 不超过容器宽度 */
background: #f5f5f5;
padding: 16px;
border-radius: 12px;
flex: 1 1 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);
}
.fab {
position: fixed;
bottom: 24px;
right: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 999;
border-radius: 50%;
width: 80px;
height: 80px;
}
</style>

View File

@@ -0,0 +1,469 @@
<template>
<v-card class="pa-4">
<v-card-title class="d-flex justify-space-between align-center">
<span>任务</span>
<v-btn color="primary" variant="tonal" @click="addTaskBefore">
<v-icon left>mdi-plus</v-icon> 新建任务
</v-btn>
</v-card-title>
<v-data-table
:headers="headers"
:items="taskData"
:items-per-page="10"
item-key="id"
class="elevation-1"
>
<!-- 自定义列渲染 -->
<template #item.nextId="{ item }">
<span>{{ item.nextId === null ? "-" : item.nextId }}</span>
</template>
<template #item.enable="{ item }">
<v-switch
color="primary"
@change="changeEnable(item)"
v-model="item.enable"
></v-switch>
</template>
<template #item.apiEnable="{ item }">
<v-switch
color="primary"
@change="edit(item)"
v-model="item.apiEnable"
></v-switch>
</template>
<template #item.running="{ item }">
<svg
v-if="item.running"
width="20"
height="20"
viewBox="0 0 48 48"
fill="#000000"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M25 34a1 1 0 011 1v10a1 1 0 01-1 1h-2a1 1 0 01-1-1V35a1 1 0 011-1h2zm8.192-3.636l7.072 7.071a1 1 0 010 1.414l-1.415 1.415a1 1 0 01-1.414 0l-7.071-7.072a1 1 0 010-1.414l1.414-1.414a1 1 0 011.414 0zm-16.97 0l1.414 1.414a1 1 0 010 1.414l-7.071 7.072a1 1 0 01-1.414 0l-1.414-1.415a1 1 0 010-1.414l7.07-7.071a1 1 0 011.415 0zM45 22a1 1 0 011 1v2a1 1 0 01-1 1H35a1 1 0 01-1-1v-2a1 1 0 011-1h10zm-32 0a1 1 0 011 1v2a1 1 0 01-1 1H3a1 1 0 01-1-1v-2a1 1 0 011-1h10zM10.565 7.737l7.071 7.07a1 1 0 010 1.415l-1.414 1.414a1 1 0 01-1.414 0l-7.071-7.071a1 1 0 010-1.414L9.15 7.737a1 1 0 011.414 0zm28.284 0l1.415 1.414a1 1 0 010 1.414l-7.072 7.071a1 1 0 01-1.414 0l-1.414-1.414a1 1 0 010-1.414l7.071-7.071a1 1 0 011.414 0zM25 2a1 1 0 011 1v10a1 1 0 01-1 1h-2a1 1 0 01-1-1V3a1 1 0 011-1h2z"
fill="#000000"
/>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 48 48" fill="#000000">
<path
d="M42.02 12.71l-1.38-1.42a1 1 0 00-1.41 0L18.01 32.5l-9.9-9.89a1 1 0 00-1.41 0l-1.41 1.41a1 1 0 000 1.41l10.6 10.61 1.42 1.41a1 1 0 001.41 0l1.41-1.41 21.92-21.92a1 1 0 00-.03-1.41z"
fill="#000000"
/>
</svg>
</template>
<template #item.startTime="{ item }">
<span>{{ formatStartTime(item.startTime) }}</span>
</template>
<template #item.key="{ item }">
<code>{{ item.key }}</code>
</template>
<!-- 如果需要可以加 actions -->
<template #item.operate="{ item }">
<!-- 这里可以放底部说明或按钮 -->
<v-icon class="mr-2" v-if="!item.running"> mdi-play </v-icon>
<v-icon class="mr-2" v-else> mdi-stop </v-icon>
<v-icon class="mr-2" @click="editTaskBefore(item)"> mdi-pencil </v-icon>
<v-icon> mdi-delete </v-icon>
</template>
</v-data-table>
</v-card>
<v-dialog v-model="taskDialog" max-width="600px">
<v-card class="rounded-xl">
<!-- 标题 -->
<v-card-title class="text-h6 font-weight-medium">
{{ isAdd ? "添加任务" : "修改任务" }}
</v-card-title>
<v-divider></v-divider>
<!-- 表单内容 -->
<v-card-text class="pt-6">
<v-container fluid>
<v-row dense>
<v-col cols="12" sm="12">
<v-text-field
label="任务名"
variant="outlined"
density="comfortable"
v-model="taskForm.name"
/>
</v-col>
<v-col cols="12" sm="6">
<v-autocomplete
label="判断目标"
item-title="name"
item-value="value"
variant="outlined"
density="comfortable"
:items="processSelect"
v-model="taskForm.processId"
/>
</v-col>
<v-col cols="12" sm="6">
<v-autocomplete
:disabled="taskForm.processId == null"
label="判断条件"
item-title="name"
item-value="value"
variant="outlined"
density="comfortable"
:items="conditionSelect"
v-model="taskForm.condition"
/>
</v-col>
<v-col cols="12" sm="6">
<v-autocomplete
label="操作目标"
item-title="name"
item-value="value"
variant="outlined"
density="comfortable"
:items="processSelect"
v-model="taskForm.operationTarget"
/>
</v-col>
<v-col cols="12" sm="6">
<v-autocomplete
:disabled="taskForm.operationTarget == null"
label="执行操作"
item-title="name"
item-value="value"
variant="outlined"
density="comfortable"
:items="operationSelect"
v-model="taskForm.operation"
/>
</v-col>
<v-col cols="12" sm="6">
<v-autocomplete
label="触发目标"
item-title="name"
item-value="value"
variant="outlined"
density="comfortable"
:items="processSelect"
v-model="taskForm.triggerTarget"
/>
</v-col>
<v-col cols="12" sm="6">
<v-autocomplete
:disabled="taskForm.triggerTarget == null"
label="触发事件"
item-title="name"
item-value="value"
variant="outlined"
density="comfortable"
:items="eventSelect"
v-model="taskForm.triggerEvent"
/>
</v-col>
<v-col cols="12" sm="6">
<v-autocomplete
label="后续任务"
item-title="name"
item-value="value"
variant="outlined"
density="comfortable"
:items="taskSelect"
v-model="taskForm.nextId"
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
label="定时任务"
variant="outlined"
density="comfortable"
v-model="taskForm.cron"
/>
</v-col>
<v-col cols="12" v-if="!isAdd">
<v-text-field
label="API"
variant="outlined"
density="comfortable"
readonly
v-model="apiUrl"
append-inner-icon="mdi-content-copy"
@click:append-inner="copyToClipboard"
>
<!-- 把按钮放进输入框右侧 -->
<template v-slot:append>
<v-btn
v-if="taskForm?.key == null"
@click="changeApi"
color="primary"
variant="tonal"
size="small"
icon="mdi-plus"
>
</v-btn>
<v-btn
v-else
@click="changeApi"
color="primary"
variant="tonal"
size="small"
icon="mdi-refresh"
>
</v-btn>
</template>
</v-text-field>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-divider></v-divider>
<!-- 底部操作按钮 -->
<v-card-actions class="justify-end pa-4">
<v-btn text @click="taskDialog = false">取消</v-btn>
<v-btn color="primary" @click="submit">确认</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { ref, onMounted } from "vue";
import { getProcessList } from "~/src/api/process";
import {
addTask,
changeTaskKey,
editTask,
editTaskEnable,
getTaskAll,
getTaskById,
} from "~/src/api/task";
import { useSnackbarStore } from "~/src/stores/snackbarStore";
import { TaskItem } from "~/src/types/tassk/task";
const snackbarStore = useSnackbarStore();
// 弹窗 & 表单
const taskDialog = ref(false);
const taskForm = ref<Partial<TaskItem>>({});
const isAdd = ref(false);
// 下拉框选项
const taskSelect = ref<any[]>([]);
const processSelect = ref<any[]>([]);
const eventSelect = ref<any[]>([]);
const operationSelect = ref<any[]>([]);
const conditionSelect = ref<any[]>([]);
// 映射表
const conditionMap = {
0: "运行中",
1: "已停止",
2: "错误",
3: "无条件",
};
const operationMap = {
0: "异步启动",
1: "异步停止",
2: "完成启动",
3: "完成停止",
};
const eventMap = {
0: "停止",
1: "启动",
2: "异常",
};
const urlBase = ref(`${window.location.origin}/api/task/api-key/`);
// 表头
const headers = [
{ title: "任务ID", key: "id" },
{ title: "任务名", key: "name" },
{ title: "下一步 ID", key: "nextId" },
{ title: "定时任务", key: "cron" },
{ title: "开始时间", key: "startTime" },
{ title: "状态", key: "running" },
{ title: "启用定时任务", key: "enable" },
{ title: "启用API", key: "apiEnable" },
{
title: "操作",
key: "operate",
headerProps: { style: "min-width: 150px;" },
},
];
const apiUrl = computed(() =>
taskForm.value?.key ? urlBase.value + taskForm.value.key : "未创建api"
);
// 任务数据
const taskData = ref<TaskItem[]>([]);
// 格式化时间
const formatStartTime = (v: Date) => {
if (!v) return "-";
try {
if (Number.isNaN(v.getTime())) return v;
return v.toLocaleString();
} catch {
return v;
}
};
onMounted(() => {
initTask();
});
// 打开添加任务弹窗
const addTaskBefore = () => {
isAdd.value = true;
taskForm.value = {}; // 清空表单
taskDialog.value = true;
};
// 打开编辑任务弹窗
const editTaskBefore = (row: TaskItem) => {
isAdd.value = false;
getTaskById(row.id).then((res) => {
taskForm.value = res.data ?? {};
});
taskDialog.value = true;
};
// 复制 API 地址
const copyToClipboard = () => {
if (!taskForm.value?.key) return;
navigator.clipboard
.writeText(urlBase.value + taskForm.value.key)
.then(() => {
snackbarStore.showSuccessMessage("复制成功");
})
.catch((err) => {
console.error("复制失败:", err);
});
};
// 初始化任务 & 下拉框选项
const initTask = () => {
getTaskAll().then((res) => {
const list = res.data ?? [];
taskData.value = list;
// 任务选择
taskSelect.value = list.map((t) => ({
name: t.id,
value: t.id,
}));
taskSelect.value.push({ name: "无", value: null });
});
// 操作/事件/条件
operationSelect.value = Object.entries(operationMap).map(([value, name]) => ({
name,
value: parseInt(value),
}));
eventSelect.value = Object.entries(eventMap).map(([value, name]) => ({
name,
value: parseInt(value),
}));
conditionSelect.value = Object.entries(conditionMap).map(([value, name]) => ({
name,
value: parseInt(value),
}));
getProcessList().then((resp) => {
if (resp.code == 0) {
processSelect.value = resp.data!.map((e) => {
return {
name: e.name,
value: e.uuid,
};
});
processSelect.value.push({
name: "无",
value: null,
});
}
});
};
// 修改任务
const edit = (item: TaskItem) => {
editTask(item).then((res) => {
if (res.code === 0) {
snackbarStore.showSuccessMessage("修改成功");
initTask();
}
});
};
// 切换启用状态
const changeEnable = (item: TaskItem) => {
editTaskEnable({ id: item.id, enable: item.enable }).then((res) => {
if (res.code === 0) {
snackbarStore.showSuccessMessage("修改成功");
initTask();
}
});
};
// 生成/刷新 API Key
const changeApi = () => {
if (!taskForm.value?.id) return;
changeTaskKey(taskForm.value.id).then((res) => {
if (res.code === 0) {
snackbarStore.showSuccessMessage("API 更新成功");
getTaskById(taskForm.value?.id).then((e) => {
Object.assign(taskForm.value, e.data);
});
}
});
};
// 提交
const submit = () => {
if (isAdd.value) {
addTask(taskForm.value).then((res) => {
if (res.code === 0) {
taskDialog.value = false;
snackbarStore.showSuccessMessage("添加成功");
initTask();
}
});
} else {
editTask(taskForm.value).then((res) => {
if (res.code === 0) {
taskDialog.value = false;
snackbarStore.showSuccessMessage("修改成功");
initTask();
}
});
}
};
</script>
<style scoped>
code {
font-size: 0.85rem;
background: rgba(0, 0, 0, 0.04);
padding: 2px 6px;
border-radius: 4px;
}
</style>