mirror of https://github.com/shunfei/cronsun
commit
3a48caf81b
11
job.go
11
job.go
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/shunfei/cronsun/conf"
|
||||
"github.com/shunfei/cronsun/log"
|
||||
"github.com/shunfei/cronsun/node/cron"
|
||||
"github.com/shunfei/cronsun/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -387,7 +388,15 @@ func (j *Job) alone() {
|
|||
}
|
||||
|
||||
func (j *Job) splitCmd() {
|
||||
j.cmd = strings.Split(j.Command, " ")
|
||||
ps := strings.SplitN(j.Command, " ", 2)
|
||||
if len(ps) == 1 {
|
||||
j.cmd = ps
|
||||
return
|
||||
}
|
||||
|
||||
j.cmd = make([]string, 0, 2)
|
||||
j.cmd = append(j.cmd, ps[0])
|
||||
j.cmd = append(j.cmd, utils.ParseCmdArguments(ps[1])...)
|
||||
}
|
||||
|
||||
func (j *Job) String() string {
|
||||
|
|
9
node.go
9
node.go
|
@ -90,7 +90,14 @@ func GetNodesBy(query interface{}) (nodes []*Node, err error) {
|
|||
return
|
||||
}
|
||||
|
||||
func ISNodeFault(id string) (bool, error) {
|
||||
func RemoveNode(query interface{}) error {
|
||||
return mgoDB.WithC(Coll_Node, func(c *mgo.Collection) error {
|
||||
return c.Remove(query)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func ISNodeAlive(id string) (bool, error) {
|
||||
n := 0
|
||||
err := mgoDB.WithC(Coll_Node, func(c *mgo.Collection) error {
|
||||
var e error
|
||||
|
|
|
@ -182,7 +182,7 @@ func monitorNodes(n Noticer) {
|
|||
switch {
|
||||
case ev.Type == client.EventTypeDelete:
|
||||
id = GetIDFromKey(string(ev.Kv.Key))
|
||||
ok, err = ISNodeFault(id)
|
||||
ok, err = ISNodeAlive(id)
|
||||
if err != nil {
|
||||
log.Warnf("query node[%s] err: %s", id, err.Error())
|
||||
continue
|
||||
|
@ -190,7 +190,8 @@ func monitorNodes(n Noticer) {
|
|||
|
||||
if ok {
|
||||
n.Send(&Message{
|
||||
Subject: "node[" + id + "] fault at time[" + time.Now().Format(time.RFC3339) + "]",
|
||||
Subject: "Node[" + id + "] break away cluster, this happed at " + time.Now().Format(time.RFC3339),
|
||||
Body: "Node breaked away cluster, this might happed when node crash or network problems.",
|
||||
To: conf.Config.Mail.To,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
type fmsState int
|
||||
|
||||
const (
|
||||
stateArgumentOutside fmsState = iota
|
||||
stateArgumentStart
|
||||
stateArgumentEnd
|
||||
)
|
||||
|
||||
var errEndOfLine = errors.New("End of line")
|
||||
|
||||
type cmdArgumentParser struct {
|
||||
s string
|
||||
i int
|
||||
length int
|
||||
state fmsState
|
||||
startToken byte
|
||||
shouldEscape bool
|
||||
currArgument []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func newCmdArgumentParser(s string) *cmdArgumentParser {
|
||||
return &cmdArgumentParser{
|
||||
s: s,
|
||||
i: -1,
|
||||
length: len(s),
|
||||
currArgument: make([]byte, 0, 16),
|
||||
}
|
||||
}
|
||||
|
||||
func (cap *cmdArgumentParser) parse() (arguments []string) {
|
||||
for {
|
||||
cap.next()
|
||||
|
||||
if cap.err != nil {
|
||||
if cap.shouldEscape {
|
||||
cap.currArgument = append(cap.currArgument, '\\')
|
||||
}
|
||||
|
||||
if len(cap.currArgument) > 0 {
|
||||
arguments = append(arguments, string(cap.currArgument))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch cap.state {
|
||||
case stateArgumentOutside:
|
||||
cap.detectStartToken()
|
||||
case stateArgumentStart:
|
||||
if !cap.detectEnd() {
|
||||
cap.detectContent()
|
||||
}
|
||||
case stateArgumentEnd:
|
||||
cap.state = stateArgumentOutside
|
||||
arguments = append(arguments, string(cap.currArgument))
|
||||
cap.currArgument = cap.currArgument[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cap *cmdArgumentParser) previous() {
|
||||
if cap.i >= 0 {
|
||||
cap.i--
|
||||
}
|
||||
}
|
||||
|
||||
func (cap *cmdArgumentParser) next() {
|
||||
if cap.length-cap.i == 1 {
|
||||
cap.err = errEndOfLine
|
||||
return
|
||||
}
|
||||
cap.i++
|
||||
}
|
||||
|
||||
func (cap *cmdArgumentParser) detectStartToken() {
|
||||
c := cap.s[cap.i]
|
||||
if c == ' ' {
|
||||
return
|
||||
}
|
||||
|
||||
switch c {
|
||||
case '\\':
|
||||
cap.startToken = 0
|
||||
cap.shouldEscape = true
|
||||
case '"', '\'':
|
||||
cap.startToken = c
|
||||
default:
|
||||
cap.startToken = 0
|
||||
cap.previous()
|
||||
}
|
||||
cap.state = stateArgumentStart
|
||||
}
|
||||
|
||||
func (cap *cmdArgumentParser) detectContent() {
|
||||
c := cap.s[cap.i]
|
||||
|
||||
if cap.shouldEscape {
|
||||
switch c {
|
||||
case ' ', '\\', cap.startToken:
|
||||
cap.currArgument = append(cap.currArgument, c)
|
||||
default:
|
||||
cap.currArgument = append(cap.currArgument, '\\', c)
|
||||
}
|
||||
cap.shouldEscape = false
|
||||
return
|
||||
}
|
||||
|
||||
if c == '\\' {
|
||||
cap.shouldEscape = true
|
||||
} else {
|
||||
cap.currArgument = append(cap.currArgument, c)
|
||||
}
|
||||
}
|
||||
|
||||
func (cap *cmdArgumentParser) detectEnd() (detected bool) {
|
||||
c := cap.s[cap.i]
|
||||
|
||||
if cap.startToken == 0 {
|
||||
if c == ' ' && !cap.shouldEscape {
|
||||
cap.state = stateArgumentEnd
|
||||
cap.previous()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if c == cap.startToken && !cap.shouldEscape {
|
||||
cap.state = stateArgumentEnd
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func ParseCmdArguments(s string) (arguments []string) {
|
||||
return newCmdArgumentParser(s).parse()
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestCmdArgumentParser(t *testing.T) {
|
||||
var args []string
|
||||
var str string
|
||||
|
||||
Convey("Parse Cmd Arguments ["+str+"]", t, func() {
|
||||
args = ParseCmdArguments(str)
|
||||
So(len(args), ShouldEqual, 0)
|
||||
})
|
||||
|
||||
str = " "
|
||||
Convey("Parse Cmd Arguments ["+str+"]", t, func() {
|
||||
args = ParseCmdArguments(str)
|
||||
So(len(args), ShouldEqual, 0)
|
||||
})
|
||||
|
||||
str = "aa bbb ccc "
|
||||
Convey("Parse Cmd Arguments ["+str+"]", t, func() {
|
||||
args = ParseCmdArguments(str)
|
||||
So(len(args), ShouldEqual, 3)
|
||||
So(args[0], ShouldEqual, "aa")
|
||||
So(args[1], ShouldEqual, "bbb")
|
||||
So(args[2], ShouldEqual, "ccc")
|
||||
})
|
||||
|
||||
str = "' \\\""
|
||||
Convey("Parse Cmd Arguments ["+str+"]", t, func() {
|
||||
args = ParseCmdArguments(str)
|
||||
So(len(args), ShouldEqual, 1)
|
||||
So(args[0], ShouldEqual, " \\\"")
|
||||
})
|
||||
|
||||
str = `a "b c"` // a "b c"
|
||||
Convey("Parse Cmd Arguments ["+str+"]", t, func() {
|
||||
args = ParseCmdArguments(str)
|
||||
So(len(args), ShouldEqual, 2)
|
||||
So(args[0], ShouldEqual, "a")
|
||||
So(args[1], ShouldEqual, "b c")
|
||||
})
|
||||
|
||||
str = `a '\''"`
|
||||
Convey("Parse Cmd Arguments ["+str+"]", t, func() {
|
||||
args = ParseCmdArguments(str)
|
||||
So(len(args), ShouldEqual, 2)
|
||||
So(args[0], ShouldEqual, "a")
|
||||
So(args[1], ShouldEqual, "'")
|
||||
})
|
||||
|
||||
str = ` \\a 'b c' c\ d\ `
|
||||
Convey("Parse Cmd Arguments ["+str+"]", t, func() {
|
||||
args = ParseCmdArguments(str)
|
||||
So(len(args), ShouldEqual, 3)
|
||||
So(args[0], ShouldEqual, "\\a")
|
||||
So(args[1], ShouldEqual, "b c")
|
||||
So(args[2], ShouldEqual, "c d ")
|
||||
})
|
||||
|
||||
str = `\`
|
||||
Convey("Parse Cmd Arguments ["+str+"]", t, func() {
|
||||
args = ParseCmdArguments(str)
|
||||
So(len(args), ShouldEqual, 1)
|
||||
So(args[0], ShouldEqual, "\\")
|
||||
})
|
||||
|
||||
str = ` \ ` // \SPACE
|
||||
Convey("Parse Cmd Arguments ["+str+"]", t, func() {
|
||||
args = ParseCmdArguments(str)
|
||||
So(len(args), ShouldEqual, 1)
|
||||
So(args[0], ShouldEqual, " ")
|
||||
})
|
||||
}
|
60
web/node.go
60
web/node.go
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
v3 "github.com/coreos/etcd/clientv3"
|
||||
"github.com/gorilla/mux"
|
||||
"gopkg.in/mgo.v2/bson"
|
||||
|
||||
"github.com/shunfei/cronsun"
|
||||
"github.com/shunfei/cronsun/conf"
|
||||
|
@ -163,3 +164,62 @@ func (n *Node) GetNodes(ctx *Context) {
|
|||
|
||||
outJSONWithCode(ctx.W, http.StatusOK, nodes)
|
||||
}
|
||||
|
||||
// DeleteNode force remove node (by ip) which state in offline or damaged.
|
||||
func (n *Node) DeleteNode(ctx *Context) {
|
||||
vars := mux.Vars(ctx.R)
|
||||
ip := strings.TrimSpace(vars["ip"])
|
||||
if len(ip) == 0 {
|
||||
outJSONWithCode(ctx.W, http.StatusBadRequest, "node ip is required.")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := cronsun.DefalutClient.Get(conf.Config.Node + ip)
|
||||
if err != nil {
|
||||
outJSONWithCode(ctx.W, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(resp.Kvs) > 0 {
|
||||
outJSONWithCode(ctx.W, http.StatusBadRequest, "can not remove a running node.")
|
||||
return
|
||||
}
|
||||
|
||||
err = cronsun.RemoveNode(bson.M{"_id": ip})
|
||||
if err != nil {
|
||||
outJSONWithCode(ctx.W, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// remove node from group
|
||||
var errmsg = "failed to remove node %s from groups, please remove it manually: %s"
|
||||
resp, err = cronsun.DefalutClient.Get(conf.Config.Group, v3.WithPrefix())
|
||||
if err != nil {
|
||||
outJSONWithCode(ctx.W, http.StatusInternalServerError, fmt.Sprintf(errmsg, ip, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
for i := range resp.Kvs {
|
||||
g := cronsun.Group{}
|
||||
err = json.Unmarshal(resp.Kvs[i].Value, &g)
|
||||
if err != nil {
|
||||
outJSONWithCode(ctx.W, http.StatusInternalServerError, fmt.Sprintf(errmsg, ip, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var nids = make([]string, 0, len(g.NodeIDs))
|
||||
for _, nid := range g.NodeIDs {
|
||||
if nid != ip {
|
||||
nids = append(nids, nid)
|
||||
}
|
||||
}
|
||||
g.NodeIDs = nids
|
||||
|
||||
if _, err = g.Put(resp.Kvs[i].ModRevision); err != nil {
|
||||
outJSONWithCode(ctx.W, http.StatusInternalServerError, fmt.Sprintf(errmsg, ip, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
outJSONWithCode(ctx.W, http.StatusNoContent, nil)
|
||||
}
|
||||
|
|
|
@ -82,6 +82,8 @@ func initRouters() (s *http.Server, err error) {
|
|||
|
||||
h = NewAuthHandler(nodeHandler.GetNodes)
|
||||
subrouter.Handle("/nodes", h).Methods("GET")
|
||||
h = NewAuthHandler(nodeHandler.DeleteNode)
|
||||
subrouter.Handle("/node/{ip}", h).Methods("DELETE")
|
||||
// get node group list
|
||||
h = NewAuthHandler(nodeHandler.GetGroups)
|
||||
subrouter.Handle("/node/groups", h).Methods("GET")
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -169,7 +169,7 @@ export default {
|
|||
},
|
||||
|
||||
formatLatest: function(latest){
|
||||
return this.$L('{begin ~ end}, on {node} took {times}', formatTime(latest.beginTime, latest.endTime), latest.node, formatDuration(latest.beginTime, latest.endTime))
|
||||
return this.$L('on {node} took {times}, {begin ~ end}', latest.node, formatDuration(latest.beginTime, latest.endTime), formatTime(latest.beginTime, latest.endTime));
|
||||
},
|
||||
|
||||
showExecuteJobModal: function(jobName, jobGroup, jobId){
|
||||
|
@ -182,4 +182,4 @@ export default {
|
|||
ExecuteJob
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -165,7 +165,7 @@ export default {
|
|||
},
|
||||
|
||||
formatTime: function(log){
|
||||
return this.$L('{begin ~ end}, took {times}', formatTime(log.beginTime, log.endTime), formatDuration(log.beginTime, log.endTime));
|
||||
return this.$L('took {times}, {begin ~ end}', formatDuration(log.beginTime, log.endTime), formatTime(log.beginTime, log.endTime));
|
||||
},
|
||||
|
||||
showExecuteJobModal: function(jobName, jobGroup, jobId){
|
||||
|
@ -177,4 +177,4 @@ export default {
|
|||
ExecuteJob
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -1,32 +1,50 @@
|
|||
<style scoped>
|
||||
.node {
|
||||
width: 130px;
|
||||
width: 140px;
|
||||
border-radius: 3px;
|
||||
padding: 4px 0;
|
||||
margin: 3px;
|
||||
display: inline-block;
|
||||
background: #e8e8e8;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
line-height: 1.9em;
|
||||
}
|
||||
|
||||
.node > i.icon.remove {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: #2185D0;
|
||||
bottom: 0;
|
||||
display: none;
|
||||
color: white;
|
||||
margin: 0;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.node:hover > i.icon.remove {display: block;}
|
||||
</style>
|
||||
<template>
|
||||
<div>
|
||||
<div class="clearfix">
|
||||
<router-link class="ui right floated primary button" to="/node/group"><i class="cubes icon"></i> {{$L('group manager')}}</router-link>
|
||||
<div class="ui label"
|
||||
<div class="ui label" v-for="item in items" v-bind:title="$L(item.title)">
|
||||
<i class="cube icon" v-bind:class="item.css"></i> {{item.nodes.length}} {{$L(item.name)}}
|
||||
<div class="ui label" v-for="group in groups" v-bind:title="$L(group.title)">
|
||||
<i class="cube icon" v-bind:class="group.css"></i> {{group.nodes.length}} {{$L(group.name)}}
|
||||
</div>
|
||||
{{$L('(total {n} nodes)', count)}}
|
||||
<div class="ui label" :title="$L('currently version')"> {{version}} </div>
|
||||
</div>
|
||||
<div class="ui relaxed list" v-for="item in items">
|
||||
<h4 v-if="item.nodes.length > 0" class="ui horizontal divider header"><i class="cube icon" v-bind:class="item.css"></i> {{$L(item.name)}} {{item.nodes.length}}</h4>
|
||||
<div v-for="node in item.nodes" class="node" v-bind:title="node.title">
|
||||
<div class="ui relaxed list" v-for="(group, groupIndex) in groups">
|
||||
<h4 v-if="group.nodes.length > 0" class="ui horizontal divider header"><i class="cube icon" v-bind:class="group.css"></i> {{$L(group.name)}} {{group.nodes.length}}</h4>
|
||||
<div v-for="(node, nodeIndex) in group.nodes" class="node" v-bind:title="node.title">
|
||||
<router-link class="item" :to="'/job?node='+node.id">
|
||||
<i class="red icon fork" v-if="node.version !== version" :title="$L('version inconsistent, node: {version}', node.version)"></i>
|
||||
{{node.id}}
|
||||
</router-link>
|
||||
<i v-if="groupIndex != 2" v-on:click="removeConfirm(groupIndex, nodeIndex, node.id)" class="icon remove"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -37,10 +55,10 @@ export default {
|
|||
name: 'node',
|
||||
data: function(){
|
||||
return {
|
||||
items: [
|
||||
{nodes:[],name:'node damaged',title:'node can not be deceted due to itself or network etc.',css:'red'},
|
||||
{nodes:[],name:'node offline',title:'node is in maintenance or is shutdown manually',css:''},
|
||||
{nodes:[],name:'node normaly',title:'node is running',css:'green'}
|
||||
groups: [
|
||||
{nodes: [], name: 'node damaged', title: 'node can not be deceted due to itself or network etc.', css:'red'},
|
||||
{nodes: [], name: 'node offline', title: 'node is in maintenance or is shutdown manually', css:''},
|
||||
{nodes: [], name: 'node normaly', title: 'node is running', css:'green'}
|
||||
],
|
||||
count: 0,
|
||||
version: ''
|
||||
|
@ -67,15 +85,26 @@ export default {
|
|||
var n = resp[i];
|
||||
n.title = n.version + "\nstarted at: " + n.up
|
||||
if (n.alived && n.connected) {
|
||||
vm.items[2].nodes.push(n);
|
||||
vm.groups[2].nodes.push(n);
|
||||
} else if (n.alived && !n.connected) {
|
||||
vm.items[0].nodes.push(n);
|
||||
vm.groups[0].nodes.push(n);
|
||||
} else {
|
||||
vm.items[1].nodes.push(n);
|
||||
vm.groups[1].nodes.push(n);
|
||||
}
|
||||
}
|
||||
vm.count = resp.length || 0;
|
||||
}).do();
|
||||
},
|
||||
|
||||
methods: {
|
||||
removeConfirm: function(groupIndex, nodeIndex, nodeId){
|
||||
if (!confirm(this.$L('are you sure to remove the node {nodeId}?', nodeId))) return;
|
||||
|
||||
var vm = this;
|
||||
this.$rest.DELETE('node/'+nodeId).onsucceed(204, (resp)=>{
|
||||
vm.groups[groupIndex].nodes.splice(nodeIndex, 1);
|
||||
}).do();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -34,11 +34,11 @@ export default {
|
|||
});
|
||||
setTimeout(()=>{
|
||||
$(vm.$el).dropdown('set exactly', vm.selected).dropdown('refresh');
|
||||
}, 200);
|
||||
}, 300);
|
||||
},
|
||||
|
||||
updated: function(){
|
||||
$(this.$el).dropdown('set exactly', this.selected);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -49,7 +49,7 @@ var language = {
|
|||
'successed': 'Successed',
|
||||
'failed': 'Failed',
|
||||
'click to select a node and re-execute job': 'Click to select a node and re-execute job',
|
||||
'{begin ~ end}, took {times}': '{0}, took {1}',
|
||||
'took {times}, {begin ~ end}': 'Took {0}, {1}',
|
||||
'executing job: {job}': 'executing job: "{0}"',
|
||||
'cancel': 'Cancel',
|
||||
'execute now': 'Execute now!',
|
||||
|
@ -73,7 +73,7 @@ var language = {
|
|||
'delete': 'Delete',
|
||||
'all groups': 'All groups',
|
||||
'all nodes': 'All nodes',
|
||||
'{begin ~ end}, on {node} took {times}': '{0}, on {1} took {2}',
|
||||
'on {node} took {times}, {begin ~ end}': 'On {0} took {1}, {2}',
|
||||
'create job': 'Create job',
|
||||
'update job': 'Update job',
|
||||
'output': 'Output',
|
||||
|
@ -129,7 +129,8 @@ var language = {
|
|||
'include nodes': 'Include nodes',
|
||||
'select nodes': 'Select nodes',
|
||||
'select groups': 'Select groups',
|
||||
'are you sure to delete the group {name}?': 'Are you sure to delete the group {0}?'
|
||||
'are you sure to delete the group {name}?': 'Are you sure to delete the group {0}?',
|
||||
'are you sure to remove the node {nodeId}?': 'Are you sure to remove the node {0}?'
|
||||
}
|
||||
|
||||
export default language;
|
||||
|
|
|
@ -50,7 +50,7 @@ var language = {
|
|||
'successed': '成功',
|
||||
'failed': '失败',
|
||||
'click to select a node and re-execute job': '点此选择节点重新执行任务',
|
||||
'{begin ~ end}, took {times}': '{0},耗时 {1}',
|
||||
'took {times}, {begin ~ end}': '耗时 {0}, {1}',
|
||||
'executing job: {job}': '执行任务:“{0}”',
|
||||
'cancel': '取消',
|
||||
'execute now': '立刻执行任务',
|
||||
|
@ -75,7 +75,7 @@ var language = {
|
|||
'delete': '删除',
|
||||
'all groups': '所有分组',
|
||||
'all nodes': '所有节点',
|
||||
'{begin ~ end}, on {node} took {times}': '{0}, 于 {1} 耗时 {2}',
|
||||
'on {node} took {times}, {begin ~ end}': '于 {0} 耗时 {1}, {2}',
|
||||
'create job': '新建任务',
|
||||
'update job': '更新任务',
|
||||
'output': '输出',
|
||||
|
@ -131,7 +131,8 @@ var language = {
|
|||
'include nodes': '包含的节点',
|
||||
'select nodes': '选择节点',
|
||||
'select groups': '选择分组',
|
||||
'are you sure to delete the group {name}?': '确定删除分组 {0}?'
|
||||
'are you sure to delete the group {name}?': '确定删除分组 {0}?',
|
||||
'are you sure to remove the node {nodeId}?': '确定删除节点 {0}?'
|
||||
}
|
||||
|
||||
export default language;
|
||||
|
|
Loading…
Reference in New Issue