mirror of
https://github.com/1Panel-dev/KubePi.git
synced 2025-12-24 13:38:10 +08:00
新增功能:node shell 新增功能:Custom Resource Definitions增加查看yaml的按钮 fixed: Terminal 中 Stream 改成 StreamWithContext 防止资源泄露 fixed: 终端默认使用sh(因为很多镜像没有bash但是一定有sh) (#255)
* 新增功能:Custom Resource Definitions增加查看yaml的按钮 * fixed: Terminal 中 Stream 改成 StreamWithContext 防止资源泄露 * fixed: 终端默认使用sh(因为很多镜像没有bash但是一定有sh) * 新增功能:node shell * fixed: node shell按钮增加权限控制
This commit is contained in:
@@ -623,6 +623,8 @@ func Install(parent iris.Party) {
|
||||
sp.Get("/:name/apigroups/{group:path}", handler.ListApiGroupResources())
|
||||
sp.Get("/:name/namespaces", handler.ListNamespace())
|
||||
sp.Get("/:name/terminal/session", handler.TerminalSessionHandler())
|
||||
//node shell
|
||||
sp.Get("/:name/node_terminal/session", handler.NodeTerminalSessionHandler())
|
||||
sp.Get("/:name/logging/session", handler.LoggingHandler())
|
||||
sp.Get("/:name/repos", handler.ListClusterRepos())
|
||||
sp.Get("/:name/repos/detail", handler.ListClusterReposDetail())
|
||||
|
||||
@@ -59,3 +59,45 @@ func (h *Handler) TerminalSessionHandler() iris.Handler {
|
||||
ctx.Values().Set("data", resp)
|
||||
}
|
||||
}
|
||||
|
||||
//node shell
|
||||
func (h *Handler) NodeTerminalSessionHandler() iris.Handler {
|
||||
return func(ctx *context.Context) {
|
||||
nodeName := ctx.URLParam("nodeName")
|
||||
|
||||
sessionID, err := terminal.GenTerminalSessionId()
|
||||
if err != nil {
|
||||
ctx.StatusCode(iris.StatusInternalServerError)
|
||||
ctx.Values().Set("message", err)
|
||||
return
|
||||
}
|
||||
clusterName := ctx.Params().GetString("name")
|
||||
c, err := h.clusterService.Get(clusterName, common.DBOptions{})
|
||||
if err != nil {
|
||||
ctx.StatusCode(iris.StatusInternalServerError)
|
||||
ctx.Values().Set("message", err)
|
||||
return
|
||||
}
|
||||
k := kubernetes.NewKubernetes(c)
|
||||
conf, err := k.Config()
|
||||
if err != nil {
|
||||
ctx.StatusCode(iris.StatusInternalServerError)
|
||||
ctx.Values().Set("message", err)
|
||||
return
|
||||
}
|
||||
client, err := k.Client()
|
||||
if err != nil {
|
||||
ctx.StatusCode(iris.StatusInternalServerError)
|
||||
ctx.Values().Set("message", err)
|
||||
return
|
||||
}
|
||||
terminal.TerminalSessions.Set(sessionID, terminal.TerminalSession{
|
||||
Id: sessionID,
|
||||
Bound: make(chan error),
|
||||
SizeChan: make(chan remotecommand.TerminalSize),
|
||||
})
|
||||
go terminal.WaitForNodeShellTerminal(client, conf, nodeName, sessionID)
|
||||
resp := TerminalResponse{ID: sessionID}
|
||||
ctx.Values().Set("data", resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/remotecommand"
|
||||
"k8s.io/utils/ptr"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const END_OF_TRANSMISSION = "\u0004"
|
||||
@@ -244,8 +247,8 @@ func startProcess(k8sClient kubernetes.Interface, cfg *rest.Config, cmd []string
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = exec.Stream(remotecommand.StreamOptions{
|
||||
ctx :=context.Background()
|
||||
err = exec.StreamWithContext(ctx,remotecommand.StreamOptions{
|
||||
Stdin: ptyHandler,
|
||||
Stdout: ptyHandler,
|
||||
Stderr: ptyHandler,
|
||||
@@ -311,3 +314,139 @@ func WaitForTerminal(k8sClient kubernetes.Interface, cfg *rest.Config, namespace
|
||||
TerminalSessions.Close(sessionId, 1, "Process exited")
|
||||
}
|
||||
}
|
||||
|
||||
//node shell
|
||||
func WaitForNodeShellTerminal(k8sClient kubernetes.Interface, cfg *rest.Config,nodeName string, sessionId string) {
|
||||
select {
|
||||
case <-TerminalSessions.Get(sessionId).Bound:
|
||||
close(TerminalSessions.Get(sessionId).Bound)
|
||||
|
||||
err := startNodeShellProcess(k8sClient, cfg, nodeName, TerminalSessions.Get(sessionId))
|
||||
|
||||
|
||||
if err != nil {
|
||||
TerminalSessions.Close(sessionId, 2, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
TerminalSessions.Close(sessionId, 1, "Process exited")
|
||||
}
|
||||
}
|
||||
func startNodeShellProcess(k8sClient kubernetes.Interface, cfg *rest.Config, nodeName string, ptyHandler PtyHandler) error {
|
||||
|
||||
/*创建特权容器*/
|
||||
containerName := "node-shell"
|
||||
podName :="node-"+nodeName+"-shell"
|
||||
namespace :="default"
|
||||
image := "alpine:latest"
|
||||
ctx :=context.Background()
|
||||
|
||||
getpod ,err :=k8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||
FieldSelector :"metadata.name="+podName+",metadata.namespace="+namespace,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//没有pod则创建
|
||||
if len(getpod.Items) == 0 {
|
||||
container := &v1.Container{
|
||||
Name: containerName,
|
||||
Image: image,
|
||||
ImagePullPolicy: v1.PullIfNotPresent,
|
||||
Stdin: true,
|
||||
StdinOnce: true,
|
||||
TTY: true,
|
||||
Command: []string{"/bin/sh"},
|
||||
SecurityContext: &v1.SecurityContext{
|
||||
Privileged : ptr.To(true),
|
||||
},
|
||||
//挂载hostPath到容器中
|
||||
VolumeMounts: []v1.VolumeMount{
|
||||
{
|
||||
Name: "host-root",
|
||||
MountPath: "/host",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
pod := &v1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: podName,
|
||||
Namespace: namespace,
|
||||
},
|
||||
Spec: v1.PodSpec{
|
||||
Containers: []v1.Container{*container},
|
||||
NodeName: nodeName,
|
||||
RestartPolicy: v1.RestartPolicyNever,
|
||||
HostNetwork: true,
|
||||
HostPID: true,
|
||||
Tolerations: []v1.Toleration{
|
||||
|
||||
{
|
||||
Key: "CriticalAddonsOnly",
|
||||
Operator: v1.TolerationOpExists,
|
||||
},
|
||||
{
|
||||
Key: "NoExecute",
|
||||
Operator: v1.TolerationOpExists,
|
||||
},
|
||||
},
|
||||
//挂载hostPath到容器中
|
||||
Volumes: []v1.Volume{
|
||||
v1.Volume{
|
||||
Name: "host-root",
|
||||
VolumeSource: v1.VolumeSource{
|
||||
HostPath: &v1.HostPathVolumeSource{
|
||||
Path: "/",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = k8sClient.CoreV1().Pods(namespace).Create(context.TODO(), pod, metav1.CreateOptions{
|
||||
});
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//等待pod创建完成
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
|
||||
|
||||
|
||||
cmd :=[]string{"sh"}
|
||||
req := k8sClient.CoreV1().RESTClient().Post().
|
||||
Resource("pods").
|
||||
Name(podName).
|
||||
Namespace(namespace).
|
||||
SubResource("exec")
|
||||
|
||||
req.VersionedParams(&v1.PodExecOptions{
|
||||
Container: containerName,
|
||||
Command: cmd,
|
||||
Stdin: true,
|
||||
Stdout: true,
|
||||
Stderr: true,
|
||||
TTY: true,
|
||||
}, scheme.ParameterCodec)
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(cfg, "POST", req.URL())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = exec.StreamWithContext(ctx,remotecommand.StreamOptions{
|
||||
Stdin: ptyHandler,
|
||||
Stdout: ptyHandler,
|
||||
Stderr: ptyHandler,
|
||||
TerminalSizeQueue: ptyHandler,
|
||||
Tty: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -147,6 +147,26 @@ export default {
|
||||
this.$router.push({ path: "/nodes/detail/" + row.metadata.name, query: { yamlShow: "true" } })
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Shell",
|
||||
icon: "iconfont iconline-terminalzhongduan",
|
||||
disabled: () => {
|
||||
return !checkPermissions({
|
||||
scope:'namespace',
|
||||
apiGroup: "",
|
||||
resource: "pods/exec",
|
||||
verb: "create"
|
||||
})
|
||||
},
|
||||
click: (row) => {
|
||||
let routeUrl = this.$router.resolve({ path: "/node_terminal" , query: {
|
||||
cluster: this.clusterName,
|
||||
namespace: row.metadata.namespace,
|
||||
nodeName: row.metadata.name,
|
||||
} })
|
||||
window.open(routeUrl.href, "_blank")
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
88
web/dashboard/src/business/cluster/nodes/terminal/index.vue
Normal file
88
web/dashboard/src/business/cluster/nodes/terminal/index.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div style="background-color: #1f2224" >
|
||||
<el-row>
|
||||
<div style="float: right;margin-top: 15px;margin-bottom: 5px;margin-right: 30px">
|
||||
<span style="font-size: 20px; color: white">node path / is mounted to shell /host</span>
|
||||
</div>
|
||||
|
||||
</el-row>
|
||||
<el-row>
|
||||
<div>
|
||||
<iframe :key="isRefresh" :src="terminal.url" :style="{'height': height}" style="width: 100%;border: 0"></iframe>
|
||||
</div>
|
||||
</el-row>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import KoFormItem from "@/components/ko-form-item/index"
|
||||
import { isLogin } from "@/api/auth"
|
||||
export default {
|
||||
name: "NodeTerminal",
|
||||
components: { KoFormItem },
|
||||
data() {
|
||||
return {
|
||||
height: "",
|
||||
isRefresh: false,
|
||||
terminal: {
|
||||
cluster: "",
|
||||
nodeName:""
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
getHeight() {
|
||||
this.height = document.body.clientHeight - 51 + "px"
|
||||
},
|
||||
getTerminalUrl() {
|
||||
return `${process.env.VUE_APP_TERMINAL_PATH}/node_shell?cluster=${this.terminal.cluster}&nodeName=${this.terminal.nodeName}`
|
||||
},
|
||||
pullingSession() {
|
||||
this.timer = setInterval(() => {
|
||||
isLogin().then(data => {
|
||||
this.items = data.data;
|
||||
}).catch(() => {
|
||||
this.$message.error(this.$t('commons.login.expires'))
|
||||
clearInterval(this.timer)
|
||||
})
|
||||
}, 5000)
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.terminal = {
|
||||
cluster: this.$route.query.cluster,
|
||||
nodeName: this.$route.query.nodeName
|
||||
}
|
||||
this.terminal.url = this.getTerminalUrl()
|
||||
this.getHeight()
|
||||
this.pullingSession()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.terminalOption {
|
||||
float: left;
|
||||
margin-left: 5px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.spanClass {
|
||||
margin-left: 20px;
|
||||
color: white;
|
||||
}
|
||||
.interval {
|
||||
margin-left: 10px;
|
||||
}
|
||||
::v-deep .scrollbar {
|
||||
.el-scrollbar__thumb {
|
||||
background-color: black;
|
||||
}
|
||||
.el-table__body-wrapper::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<layout-content header="Custom Resource">
|
||||
<complex-table :data="data" @search="search" :selects.sync="selects" v-loading="loading"
|
||||
:pagination-config="paginationConfig" :search-config="searchConfig">
|
||||
:pagination-config="paginationConfig" :search-config="searchConfig">
|
||||
<template #header>
|
||||
<el-button type="primary" size="small" :disabled="selects.length===0" @click="onDelete()"
|
||||
v-has-permissions="{scope:'namespace',apiGroup:'apiextensions.k8s.io',resource:'customresourcedefinitions',verb:'delete'}">
|
||||
<el-button type="primary" size="small" :disabled="selects.length === 0" @click="onDelete()"
|
||||
v-has-permissions="{ scope: 'namespace', apiGroup: 'apiextensions.k8s.io', resource: 'customresourcedefinitions', verb: 'delete' }">
|
||||
{{ $t("commons.button.delete") }}
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -15,14 +15,20 @@
|
||||
<el-table-column label="Kind" show-overflow-tooltip prop="kind">
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('commons.table.name')" show-overflow-tooltip prop="metadata.name">
|
||||
<template v-slot:default="{ row }">
|
||||
<el-link @click="openEditPage(row, '0')" class="iconfont iconhuaban88"><span>
|
||||
{{ row.metadata.name }}
|
||||
</span></el-link>
|
||||
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="show" :label="$t('business.namespace.namespace')" prop="metadata.namespace">
|
||||
<template v-slot:default="{row}">
|
||||
<template v-slot:default="{ row }">
|
||||
{{ row.metadata.namespace }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('commons.table.created_time')" prop="metadata.creationTimestamp" fix>
|
||||
<template v-slot:default="{row}">
|
||||
<template v-slot:default="{ row }">
|
||||
{{ row.metadata.creationTimestamp | age }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -34,13 +40,13 @@
|
||||
<script>
|
||||
import LayoutContent from "@/components/layout/LayoutContent"
|
||||
import ComplexTable from "@/components/complex-table"
|
||||
import {downloadYaml} from "@/utils/actions"
|
||||
import { downloadYaml } from "@/utils/actions"
|
||||
import KoTableOperations from "@/components/ko-table-operations"
|
||||
import {
|
||||
deleteResource, getResource,
|
||||
listResourceByGroup
|
||||
} from "@/api/customresourcedefinitions"
|
||||
import {checkPermissions} from "@/utils/permission"
|
||||
import { checkPermissions } from "@/utils/permission"
|
||||
|
||||
export default {
|
||||
name: "CRList",
|
||||
@@ -54,7 +60,7 @@ export default {
|
||||
default: "Cluster"
|
||||
}
|
||||
},
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
data: [],
|
||||
selects: [],
|
||||
@@ -66,19 +72,7 @@ export default {
|
||||
label: this.$t("commons.button.edit_yaml"),
|
||||
icon: "el-icon-edit",
|
||||
click: (row) => {
|
||||
this.$router.push({
|
||||
name: "CustomResourceEdit",
|
||||
params: {
|
||||
name: row.metadata.name,
|
||||
cluster: this.cluster,
|
||||
version: this.version,
|
||||
group: this.group,
|
||||
names: this.names,
|
||||
},
|
||||
query: {
|
||||
namespace: row.metadata.namespace
|
||||
}
|
||||
})
|
||||
this.openEditPage(row, '1')
|
||||
},
|
||||
disabled: () => {
|
||||
return !checkPermissions({
|
||||
@@ -89,6 +83,13 @@ export default {
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: this.$t("commons.button.view_yaml"),
|
||||
icon: "el-icon-view",
|
||||
click: (row) => {
|
||||
this.openEditPage(row, '0')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: this.$t("commons.button.download_yaml"),
|
||||
icon: "el-icon-download",
|
||||
@@ -123,7 +124,7 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
search (resetPage) {
|
||||
search(resetPage) {
|
||||
this.loading = true
|
||||
if (resetPage) {
|
||||
this.paginationConfig.currentPage = 1
|
||||
@@ -134,14 +135,14 @@ export default {
|
||||
this.paginationConfig.total = res.total
|
||||
})
|
||||
},
|
||||
onDelete (row) {
|
||||
onDelete(row) {
|
||||
this.$confirm(
|
||||
this.$t("commons.confirm_message.delete"),
|
||||
this.$t("commons.message_box.prompt"), {
|
||||
confirmButtonText: this.$t("commons.button.confirm"),
|
||||
cancelButtonText: this.$t("commons.button.cancel"),
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
confirmButtonText: this.$t("commons.button.confirm"),
|
||||
cancelButtonText: this.$t("commons.button.cancel"),
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
this.ps = []
|
||||
if (row) {
|
||||
this.ps.push(deleteResource(this.cluster, this.version, this.group, this.names, row.metadata.namespace.row.metadata.name))
|
||||
@@ -167,8 +168,24 @@ export default {
|
||||
}
|
||||
})
|
||||
},
|
||||
openEditPage(row, editable) {
|
||||
this.$router.push({
|
||||
name: "CustomResourceEdit",
|
||||
params: {
|
||||
name: row.metadata.name,
|
||||
cluster: this.cluster,
|
||||
version: this.version,
|
||||
group: this.group,
|
||||
names: this.names,
|
||||
editable: editable
|
||||
},
|
||||
query: {
|
||||
namespace: row.metadata.namespace
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
created () {
|
||||
created() {
|
||||
this.cluster = this.$route.query.cluster
|
||||
this.show = this.scope === "Namespaced"
|
||||
this.search()
|
||||
@@ -176,6 +193,4 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<layout-content :header="$t('commons.button.edit')" :back-to="{name: 'CustomResourceDefinitions'}" v-loading="loading">
|
||||
<layout-content :header="editable=='1'?$t('commons.button.edit'):$t('commons.button.view_yaml')" :back-to="{name: 'CustomResourceDefinitions'}" v-loading="loading">
|
||||
<yaml-editor :value="yaml" :is-edit="true" ref="yaml_editor"></yaml-editor>
|
||||
<div class="bottom-button">
|
||||
<div class="bottom-button" v-if="editable=='1'">
|
||||
<el-button @click="onCancel()">{{ $t("commons.button.cancel") }}</el-button>
|
||||
<el-button v-loading="loading" @click="onSubmit" type="primary">
|
||||
{{ $t("commons.button.submit") }}
|
||||
@@ -22,7 +22,8 @@ export default {
|
||||
name: String,
|
||||
names: String,
|
||||
version: String,
|
||||
group: String
|
||||
group: String,
|
||||
editable: String
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<el-row>
|
||||
<div class="terminalOption" v-if="terminal.type ==='terminal'">
|
||||
<el-radio-group size="mini" @change="changeConditions()" v-model="shell">
|
||||
<el-radio-button label="bash"></el-radio-button>
|
||||
<el-radio-button label="sh"></el-radio-button>
|
||||
<el-radio-button label="bash"></el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="terminalOption">
|
||||
@@ -102,7 +102,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
height: "",
|
||||
shell: "bash",
|
||||
shell: "sh",
|
||||
isRefresh: false,
|
||||
follow: true,
|
||||
/*是否查看上次失败日志*/
|
||||
|
||||
@@ -151,7 +151,7 @@ const Clusters = {
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/resource/:group/:names/:version/:name/edit",
|
||||
path: "/resource/:group/:names/:version/:name/edit/:editable",
|
||||
component: () => import("@/business/custom-resource/cr/edit"),
|
||||
hidden: true,
|
||||
props: true,
|
||||
|
||||
16
web/dashboard/src/router/modules/node_terminal.js
Normal file
16
web/dashboard/src/router/modules/node_terminal.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import terminals from "@/business/cluster/nodes/terminal"
|
||||
|
||||
const Terminal = {
|
||||
path: "/node_terminal?:cluster/:nodeName",
|
||||
hidden: true,
|
||||
component: terminals,
|
||||
name: "NodeTerminal",
|
||||
children: [
|
||||
{
|
||||
path: "/node_terminal",
|
||||
component: () => import("@/business/cluster/nodes/terminal"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export default Terminal
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Routes} from '@angular/router';
|
||||
import {AppComponent} from "./app.component";
|
||||
import {TerminalComponent} from "./terminal/terminal.component";
|
||||
import {NodeShellTerminalComponent} from "./terminal/node_terminal.component";
|
||||
import {LoggingComponent} from "./logging/logging.component";
|
||||
|
||||
|
||||
@@ -10,6 +11,7 @@ export const routes: Routes = [
|
||||
{path: '', redirectTo: 'app', pathMatch: 'full'},
|
||||
{path: 'app', component: TerminalComponent},
|
||||
{path: 'logging', component: LoggingComponent},
|
||||
{path: 'node_shell', component: NodeShellTerminalComponent},
|
||||
{path: '*', redirectTo: '', pathMatch: 'full'},
|
||||
]
|
||||
},
|
||||
|
||||
225
web/terminal/src/app/terminal/node_terminal.component.ts
Normal file
225
web/terminal/src/app/terminal/node_terminal.component.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import {AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild} from '@angular/core';
|
||||
import {Terminal} from 'xterm';
|
||||
import {FitAddon} from 'xterm-addon-fit';
|
||||
import {ReplaySubject, Subject} from "rxjs";
|
||||
import {ShellFrame, SJSCloseEvent, SJSMessageEvent} from "./terminal";
|
||||
import {ActivatedRoute, Router} from "@angular/router";
|
||||
import {debounce} from 'lodash';
|
||||
import {takeUntil} from "rxjs/operators";
|
||||
import {TerminalService} from "./terminal.service";
|
||||
|
||||
declare let SockJS: any;
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-node-terminal',
|
||||
templateUrl: './terminal.component.html',
|
||||
styleUrls: ['./terminal.component.css']
|
||||
})
|
||||
|
||||
export class NodeShellTerminalComponent implements AfterViewInit {
|
||||
@ViewChild('anchor', {static: true}) anchorRef: ElementRef;
|
||||
term: Terminal;
|
||||
nodeName: string;
|
||||
|
||||
clusterName: string;
|
||||
private connecting_: boolean
|
||||
private connectionClosed_: boolean
|
||||
private conn_: WebSocket
|
||||
private connected_ = false;
|
||||
private debouncedFit_: Function
|
||||
private connSubject_ = new ReplaySubject<ShellFrame>(100)
|
||||
private incommingMessage$_ = new Subject<ShellFrame>()
|
||||
// private readonly endpoint_=
|
||||
private readonly unsubscribe_ = new Subject<void>()
|
||||
private readonly keyEvent$_ = new ReplaySubject<KeyboardEvent>(2)
|
||||
|
||||
|
||||
constructor(private activatedRoute_: ActivatedRoute,
|
||||
private readonly cdr_: ChangeDetectorRef,
|
||||
private _router: Router,
|
||||
private terminalService: TerminalService
|
||||
) {
|
||||
this.clusterName = this.activatedRoute_.snapshot.queryParams["cluster"]
|
||||
this.nodeName = this.activatedRoute_.snapshot.queryParams["nodeName"]
|
||||
}
|
||||
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.nodeName) {
|
||||
this.setupConnection()
|
||||
} else {
|
||||
alert("please set param: nodeName name ")
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribe_.next();
|
||||
this.unsubscribe_.complete();
|
||||
|
||||
if (this.conn_) {
|
||||
this.conn_.close();
|
||||
}
|
||||
|
||||
if (this.connSubject_) {
|
||||
this.connSubject_.complete();
|
||||
}
|
||||
|
||||
if (this.term) {
|
||||
this.term.dispose();
|
||||
}
|
||||
this.incommingMessage$_.complete();
|
||||
}
|
||||
|
||||
|
||||
disconnect(): void {
|
||||
if (this.conn_) {
|
||||
this.conn_.close();
|
||||
}
|
||||
|
||||
if (this.connSubject_) {
|
||||
this.connSubject_.complete();
|
||||
this.connSubject_ = new ReplaySubject<ShellFrame>(100);
|
||||
}
|
||||
|
||||
if (this.term) {
|
||||
this.term.dispose();
|
||||
}
|
||||
|
||||
this.incommingMessage$_.complete();
|
||||
this.incommingMessage$_ = new Subject<ShellFrame>();
|
||||
}
|
||||
|
||||
|
||||
initTerm(): void {
|
||||
if (this.connSubject_) {
|
||||
this.connSubject_.complete()
|
||||
this.connSubject_ = new ReplaySubject<ShellFrame>(100);
|
||||
}
|
||||
|
||||
if (this.term) {
|
||||
this.term.dispose()
|
||||
}
|
||||
|
||||
this.term = new Terminal({
|
||||
fontSize: 14,
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
bellStyle: 'sound',
|
||||
cursorBlink: true,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
this.term.loadAddon(fitAddon);
|
||||
this.term.open(this.anchorRef.nativeElement);
|
||||
this.debouncedFit_ = debounce(() => {
|
||||
fitAddon.fit()
|
||||
this.cdr_.markForCheck();
|
||||
}, 100)
|
||||
this.debouncedFit_();
|
||||
window.addEventListener('resize', () => this.debouncedFit_())
|
||||
this.connSubject_.pipe(takeUntil(this.unsubscribe_)).subscribe(frame => {
|
||||
this.handleConnectionMessage(frame);
|
||||
})
|
||||
this.term.onData(this.onTerminalSendingString.bind(this))
|
||||
this.term.onResize(this.onTerminalResize.bind(this))
|
||||
this.term.onKey(event => {
|
||||
this.keyEvent$_.next(event.domEvent)
|
||||
})
|
||||
this.cdr_.markForCheck()
|
||||
}
|
||||
|
||||
private onConnectionOpen(sessionId: string): void {
|
||||
const startData = {Op: 'bind', SessionID: sessionId};
|
||||
|
||||
this.conn_.send(JSON.stringify(startData));
|
||||
this.connSubject_.next(startData);
|
||||
this.connected_ = true;
|
||||
this.connecting_ = false;
|
||||
this.connectionClosed_ = false;
|
||||
|
||||
// Make sure the terminal is with correct display size.
|
||||
this.onTerminalResize();
|
||||
|
||||
// Focus on connection
|
||||
this.term.focus();
|
||||
this.cdr_.markForCheck();
|
||||
}
|
||||
|
||||
|
||||
private async setupConnection(): Promise<void> {
|
||||
if (!(this.nodeName && !this.connecting_)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.connecting_ = true;
|
||||
this.connectionClosed_ = false;
|
||||
|
||||
try {
|
||||
const {data} = await this.terminalService.createNodeShellTerminalSession(this.clusterName, this.nodeName).toPromise()
|
||||
const id = data.id
|
||||
this.conn_ = new SockJS(`/kubepi/api/v1/ws/terminal/sockjs?${id}`);
|
||||
this.conn_.onopen = this.onConnectionOpen.bind(this, id);
|
||||
this.conn_.onmessage = this.onConnectionMessage.bind(this);
|
||||
this.conn_.onclose = this.onConnectionClose.bind(this);
|
||||
this.initTerm()
|
||||
this.cdr_.markForCheck();
|
||||
} catch (e:any) {
|
||||
this.term.write(e.error.message)
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnectionMessage(frame: ShellFrame): void {
|
||||
if (frame.Op === 'stdout') {
|
||||
if (frame.Data)
|
||||
this.term.write(frame.Data);
|
||||
}
|
||||
|
||||
if (frame.Op === 'toast') {
|
||||
alert(frame.Data)
|
||||
}
|
||||
|
||||
this.incommingMessage$_.next(frame);
|
||||
this.cdr_.markForCheck();
|
||||
}
|
||||
|
||||
private onConnectionMessage(evt: SJSMessageEvent): void {
|
||||
const msg = JSON.parse(evt.data);
|
||||
this.connSubject_.next(msg);
|
||||
}
|
||||
|
||||
private onConnectionClose(_evt?: SJSCloseEvent): void {
|
||||
this.term.write("\n\n******** The connection failed. Unsupported, interrupted or timed out. ********")
|
||||
if (!this.connected_) {
|
||||
return;
|
||||
}
|
||||
this.conn_.close();
|
||||
this.connected_ = false;
|
||||
this.connecting_ = false;
|
||||
this.connectionClosed_ = true;
|
||||
// alert(_evt?.reason)
|
||||
this.cdr_.markForCheck();
|
||||
}
|
||||
|
||||
private onTerminalSendingString(str: string): void {
|
||||
if (this.connected_) {
|
||||
this.conn_.send(JSON.stringify({
|
||||
Op: 'stdin',
|
||||
Data: str,
|
||||
Cols: this.term.cols,
|
||||
Rows: this.term.rows,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
private onTerminalResize(): void {
|
||||
if (this.connected_) {
|
||||
this.conn_.send(
|
||||
JSON.stringify({
|
||||
Op: 'resize',
|
||||
Cols: this.term.cols,
|
||||
Rows: this.term.rows,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,11 @@ export class TerminalService {
|
||||
}()
|
||||
return this.http.get<any>(url)
|
||||
}
|
||||
createNodeShellTerminalSession(clusterName: string, nodeName: string): Observable<any> {
|
||||
const url = function () {
|
||||
let baseUrl = `/kubepi/api/v1/clusters/${clusterName}/node_terminal/session?nodeName=${nodeName}`
|
||||
return baseUrl
|
||||
}()
|
||||
return this.http.get<any>(url)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user