open socket

create pod, kubectl

only pods
This commit is contained in:
Nancy Butler 2019-10-01 11:57:00 -07:00
parent b530db815f
commit 6e9cbd5b55
13 changed files with 1953 additions and 48 deletions

View File

@ -185,46 +185,7 @@ $transition-duration: 150ms;
border-color: transparent;
}
&.vs--open .vs__selected {
position: absolute;
opacity: .4;
}
&.vs--searching .vs__selected {
display: none;
}
}
.vs__selected {
display: flex;
align-items: center;
background-color: #f0f0f0;
border: 1px solid var(--dropdown-border);
border-radius: 4px;
color: #333;
margin: 4px 2px 0px 2px;
padding: 0 0.25em;
}
.vs__deselect {
display: inline-flex;
appearance: none;
margin-left: 4px;
padding: 0;
border: 0;
cursor: pointer;
background: none;
fill: rgba(60,60,60,0.26);
text-shadow: 0 1px 0 #fff
}
/* States */
.vs--single {
.vs__selected {
background-color: transparent;
border-color: transparent;
}
&.vs--open .vs__selected {
position: absolute;
// position: absolute;
opacity: .4;
}
&.vs--searching .vs__selected {

View File

@ -0,0 +1,84 @@
<script>
import { mapState } from 'vuex';
export default {
computed: {
...mapState('auth', ['principal']),
podConfig() {
const config = {
apiVersion: 'v1',
kind: 'Pod',
metadata: { name: this.expectedPodName },
spec: {
containers: [
{
name: 'ubuntu xenial',
image: 'ubuntu:xenial',
imagePullPolicy: 'Always',
stdin: true
}
]
}
};
return config;
},
expectedPodName() {
const userName = this.principal.name.toLowerCase();
// TODO make pod different clusters
const cluster = 'local';
return `manage-${ cluster }-${ userName }`;
}
},
methods: {
findPod() {
return fetch(`${ window.location.origin }/api/v1/namespaces/default/pods/${ this.expectedPodName }`)
.then((res) => {
if (res.ok) {
return res.json();
} else {
throw (res);
}
})
.then((json) => {
return json;
})
.catch((err) => {
console.log('error getting pod: ', err);
return this.makePod();
});
},
makePod() {
return fetch(`${ window.location.origin }/api/v1/namespaces/default/pods`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.podConfig )
}).then(res => res.json())
.then((json) => {
return json;
});
},
deletePod() {
fetch(`${ window.location.origin }/api/v1/namespaces/default/pods/${ this.expectedPodName }`, { method: 'DELETE' }).then(res => res.json()).then(json => console.log(json));
},
async openModal() {
const resource = await this.findPod();
console.log('opening term for: ', resource);
this.$store.dispatch('shell/defineSocket', { resource, action: 'openShell' });
}
}
};
</script>
<template>
<button class="btn-sm bg-secondary" @click="openModal">
kubectl
</button>
</template>
<style lang='scss' scoped>
</style>

View File

@ -0,0 +1,110 @@
<script>
import { saveAs } from 'file-saver';
export default {
props: { backlog: { type: Array, default: () => [] } },
data() {
return { showLast: false };
},
computed: {
prettyLines() {
return this.backlog.map(line => this.withLocale(line));
}
},
methods: {
withLocale(line) {
const matches = line.match(/^\[?([^ \]]+)\]?\s?/) || [];
let date, message, localDate;
if (matches[1] && this.isDate(matches[1]) ) {
date = new Date(matches[1]);
message = line.substr(matches[1].length);
localDate = `${ date.toLocaleDateString() } ${ date.toLocaleTimeString() }`;
}
return { localDate, message };
},
isDate(date) {
return new Date(date) !== 'Invalid Date' && !isNaN(new Date(date));
},
// toggleLast(e) {
// this.$store.dispatch('shell/closeSocket')
// .then(() => {
// this.$store.dispatch('shell/defineSocket', { showLast: !this.showLast });
// });
// },
scrollToTop() {
const viewPort = this.$refs.logs;
viewPort.scrollTop = 0;
},
scrollToBottom() {
const viewPort = this.$refs.logs;
viewPort.scrollTop = viewPort.scrollHeight;
},
clear() {
this.$store.commit('shell/clearBacklog');
},
download() {
const blob = new Blob(this.backlog, { type: 'text/plain;charset=utf-8' });
const fileName = `${ this.$store.state.shell.container.name }.log`;
saveAs(blob, fileName);
}
}
};
</script>
<template>
<div>
<div ref="logs" class="logs terminal">
<div
v-for="(line, i) in prettyLines"
:key="`${line}--${i}`"
>
<span class="datestring">{{ line.localDate }}</span>{{ line.message }}
</div>
</div>
<div class="controls-bottom">
<button class="btn btn-sm bg-primary" type="button" @click="scrollToTop">
Scroll to Top
</button>
<button class="btn btn-sm bg-primary" type="button" @click="scrollToBottom">
Scroll to Bottom
</button>
<button class="btn btn-sm bg-primary" @click="clear">
Clear
</button>
<button class="btn btn-sm bg-primary" @click="download">
Download Logs
</button>
</div>
<!-- <div class="toggle">
<input id="togglelast" :value="showLast" type="checkbox" @click="toggleLast">
<label for="togglelast">previous container </label>
</div> -->
</div>
</template>
<style scoped lang='scss'>
.logs{
height: 80%;
overflow-y: scroll;
max-height: 80vh;
min-height: 500px;
scroll-behavior: smooth;
}
.controls-bottom{
display: flex;
justify-content: center;
& button{
margin: 5px;
flex-grow: 0;
}
}
.toggle{
color: var( --input-label);
}
</style>

View File

@ -0,0 +1,169 @@
/* eslint-disable vue/html-quotes */
<script>
import { mapState } from 'vuex';
import { base64Encode } from '@/utils/crypto';
import Terminal from '@/components/ContainerExec/Terminal';
import Logs from '@/components/ContainerExec/Logs';
export default {
components: { Terminal, Logs },
data() {
return { terminal: null };
},
computed: {
...mapState('shell', ['containers', 'container', 'socket', 'mode', 'backlog']),
isOpen() {
return this.socket.readyState === 1;
}
},
watch: {
mode(newProp) {
if (newProp) {
this.$modal.show('terminal');
}
}
},
methods: {
hide() {
this.$store.dispatch('shell/closeSocket');
this.$store.commit('shell/closeModal');
},
selectContainer(container) {
this.$store.dispatch('shell/closeSocket')
.then(() => {
this.$store.dispatch('shell/defineSocket', { container });
});
},
resized({ rows, cols }) {
if (this.isOpen) {
const message = `4${ base64Encode(JSON.stringify({
Width: cols,
Height: rows
})) }`;
this.socket.send(message);
}
},
terminalReady(payload) {
this.terminal = payload.terminal;
if (this.isOpen) {
this.backlog.forEach(log => payload.terminal.write(log));
}
},
terminalInput(input) {
this.$store.commit('shell/sendInput', { input });
}
}
};
</script>
<template>
<modal
ref="modal"
name="terminal"
:resizable="true"
:adaptive="true"
height="auto"
@before-close="hide"
>
<div v-if="containers.length>1" class="controls-top">
<span>
</span>
<v-select
:clearable="false"
:value="container"
:options="containers"
label="name"
@input="selectContainer"
>
</v-select>
</div>
<div v-if="containers.length<=1" class="label-top">
<span>
{{ container.name }}
</span>
</div>
<!-- use bound key here to make the terminal re-render every time container changes -->
<Terminal
v-if="mode==='openShell'"
:key="container.name"
:lines="backlog"
:class=" { disconnected: !isOpen }"
:is-open="isOpen"
@clearBacklog="$store.commit('shell/clearBacklog')"
@resized="resized"
@input="terminalInput"
@terminalReady="terminalReady"
/>
<Logs v-if="mode==='openLogs'" :class=" { disconnected: !isOpen }" :backlog="backlog" />
</modal>
</template>
<style lang="scss">
@import '@/node_modules/xterm/css/xterm.css';
.v--modal-box{
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px -2px rgba(0,0,0,0.3);
& > :nth-child(2){
flex-grow: 1;
}
}
.terminal, .xterm, .xterm-viewport{
width: 100%;
height: 100%;
background-color: var(--nav-bg );
& .datestring {
color: var(--input-label)
}
& div {
color: rgba(39,170,94,1);
}
}
.controls-top, .label-top {
color: var(--dropdown-text);
margin: 5px;
display: flex;
align-items: center;
& .v-select{
flex-grow: 1;
flex-basis: 0%;
border: none;
& .vs__dropdown-toggle {
border: none;
}
& .vs__selected{
position: absolute;
top: 25%;
color: var(--dropdown-text)
}
}}
.label-top{
margin: 15px;
}
.v--modal {
background-color: var(--box-bg);
}
.disconnected{
& > :nth-child(1) {
border: 1px solid red;
}
}
.socket-status{
// border: 1px dashed red;
align-self: flex-end;
position: relative;
top: 20px;
right: 10px;
z-index: 1;
}
</style>

View File

@ -0,0 +1,112 @@
<script>
export default {
props: {
allowInput: { type: Boolean, default: true },
config: {
type: Object,
default: () => {
return {
cursorblink: true,
useStyle: true,
fontSize: 12,
};
}
},
theme: {
type: Object,
default: () => {
return {
background: '#141419',
cursor: 'rgba(39,170,94,1)',
foreground: 'rgba(39,170,94,1)'
};
}
},
lines: {
type: Array,
default: () => []
}
},
data() {
return {
terminal: null,
fitAddon: null,
xterm: null
};
},
watch: {
lines(newLines) {
if (this.terminal && newLines.length) {
newLines.forEach(line => this.terminal.write(line));
this.$emit('clearBacklog');
}
}
},
beforeDestroy() {
this.terminal.dispose();
},
mounted() {
// dynamically import xterm in mounted() to avoid problems with ssr
import('xterm')
.then((xterm) => {
console.log('xterm imported');
this.xterm = xterm;
})
.then(() => {
return import('xterm-addon-fit');
})
.then((fitAddon) => {
this.fitAddon = new fitAddon.FitAddon();
})
.then(() => {
this.drawTerminal();
});
},
methods: {
drawTerminal() {
const vm = this;
const terminal = new this.xterm.Terminal({
...vm.config,
disableStdin: !vm.allowInput,
theme: this.theme
});
this.terminal = terminal;
this.terminal.loadAddon(this.fitAddon);
this.terminal.open(this.$refs.terminal);
this.fitTerminal();
this.lines.forEach(line => this.terminal.write(line));
this.$emit('clearBacklog');
terminal.onData((input) => {
this.$emit('input', input);
});
},
fitTerminal(arg) {
if (this.fitAddon) {
this.fitAddon.fit();
this.$emit('resized', this.fitAddon.proposeDimensions());
}
},
}
};
</script>
<template>
<div id="terminal-container">
<div id="terminal" ref="terminal" class="terminal">
</div>
<resize-observer @notify="fitTerminal" />
</div>
</template>
<style lang="scss">
#terminal-container {
padding: 5px;
height: 100%;
overflow: hidden;
& .xterm-viewport {
scroll-behavior: smooth;
}
}
</style>

View File

@ -7,6 +7,8 @@ import { mapPref, THEME, EXPANDED_GROUPS } from '@/store/prefs';
import ActionMenu from '@/components/ActionMenu';
import NamespaceFilter from '@/components/nav/NamespaceFilter';
import ClusterSwitcher from '@/components/nav/ClusterSwitcher';
import ShellSocket from '@/components/ContainerExec/ShellSocket';
import LaunchKubectl from '@/components/ContainerExec/LaunchKubectl';
import Group from '@/components/nav/Group';
import { COUNT } from '@/config/types';
@ -15,7 +17,9 @@ export default {
ClusterSwitcher,
NamespaceFilter,
ActionMenu,
Group
Group,
ShellSocket,
LaunchKubectl
},
middleware: ['authenticated'],
@ -117,6 +121,7 @@ export default {
</div>
<div class="header-middle">
<LaunchKubectl />
</div>
<v-popover
@ -172,7 +177,7 @@ export default {
<main>
<nuxt />
</main>
<ShellSocket />
<ActionMenu />
</div>
</template>

View File

@ -72,6 +72,7 @@ module.exports = {
'~/plugins/vue-clipboard2',
'~/plugins/v-select',
'~/plugins/transitions',
{ src: '~plugins/vue-js-modal' },
{ src: '~/plugins/js-yaml', ssr: false },
{ src: '~/plugins/codemirror', ssr: false },
{ src: '~/plugins/resize', ssr: false },
@ -164,7 +165,6 @@ function onProxyReqWs(proxyReq, req, socket, options, head) {
proxyReq.setHeader('origin', options.target.href);
proxyReq.setHeader('x-forwarded-host', req.headers['host']);
proxyReq.setHeader('x-api-host', req.headers['host']);
socket.on('error', (err) => {
console.error('Proxy WS Error:', err);
});

View File

@ -53,12 +53,15 @@
"v-tooltip": "^2.0.2",
"vue-clipboard2": "^0.3.1",
"vue-codemirror": "^4.0.6",
"vue-js-modal": "^1.3.31",
"vue-multiselect": "^2.1.6",
"vue-native-websocket": "^2.0.13",
"vue-resize": "^0.4.5",
"vue-select": "^3.1.0",
"vue2-transitions": "^0.3.0",
"vuex-persistedstate": "^2.5.4",
"xterm": "^4.0.2",
"xterm-addon-fit": "^0.2.1",
"yaml-js": "^0.2.3"
},
"devDependencies": {

4
plugins/vue-js-modal.js Normal file
View File

@ -0,0 +1,4 @@
import Vue from 'vue';
import VModal from 'vue-js-modal/dist/ssr.index';
Vue.use(VModal);

File diff suppressed because one or more lines are too long

View File

@ -79,9 +79,26 @@ export const getters = {
}
}
const openShell = {
action: 'openShell',
enabled: true,
icon: 'icon icon-fw icon-chevron-right',
label: 'Execute Shell',
total: 1,
};
const openLogs = {
action: 'openLogs',
enabled: true,
icon: 'icon icon-fw icon-chevron-right',
label: 'View Logs',
total: 1,
};
const out = _filter(map);
return out;
return selected[0].kind === 'Pod' ? {
...out, openShell, openLogs
} : { ...out };
},
isSelected: state => (resource) => {
@ -146,8 +163,12 @@ export const actions = {
return _execute(state.tableSelected, action, args);
},
execute({ state }, { action, args }) {
return _execute(state.resources, action, args);
execute({ state, dispatch }, { action, args }) {
if (action.action === 'openShell' || action.action === 'openLogs') {
dispatch('shell/defineSocket', { resource: state.resources[0], action: action.action }, { root: true });
} else {
return _execute(state.resources, action, args);
}
},
};
@ -192,7 +213,6 @@ function _filter(map, disableAll = false) {
function _execute(resources, action, args) {
args = args || [];
if ( resources.length > 1 && action.bulkAction ) {
const fn = resources[0][action.bulkAction];

119
store/shell.js Normal file
View File

@ -0,0 +1,119 @@
import { base64Decode, base64Encode } from '@/utils/crypto/index';
export const state = () => {
return {
socket: {},
containers: [],
container: {},
resource: null,
mode: null,
backlog: [],
toSend: [],
};
};
export const actions = {
closeSocket({ commit, state }) {
return new Promise((resolve) => {
if (state.socket) {
state.socket.close();
commit('clearBacklog');
commit('socketOpened', { socket: {} });
}
resolve();
});
},
defineSocket({ dispatch, state }, payload) {
const resource = payload.resource ? payload.resource : state.resource;
const action = payload.action ? payload.action : state.mode;
const containers = resource.spec.containers.filter((container) => {
return container.name !== 'istio';
});
const currentContainer = payload.container ? payload.container : containers[0];
const showLast = payload.showLast ? payload.showLast : false;
let protocol = null;
let url = null;
switch (action) {
case 'openShell':
protocol = 'base64.channel.k8s.io';
url = `${ window.location.origin.replace('https', 'wss') }/api/v1/namespaces/${ resource.metadata.namespace }/pods/${ resource.metadata.name }/exec?container=${ currentContainer.name }&stdout=1&stdin=1&stderr=1&tty=1&command=sh`;
break;
case 'openLogs':
protocol = 'base64.binary.k8s.io';
url = `${ window.location.origin.replace('https', 'wss') }/api/v1/namespaces/${ resource.metadata.namespace }/pods/${ resource.metadata.name }/log?container=${ currentContainer.name }&tailLines=500&follow=true&timestamps=true&previous=${ showLast }`;
break;
default:
protocol = 'base64.channel.k8s.io';
url = `${ window.location.origin.replace('https', 'wss') }/api/v1/namespaces/${ resource.metadata.namespace }/pods/${ resource.metadata.name }/exec?container=${ currentContainer.name }`;
}
console.log('socket url: ', url);
dispatch('openSocket', {
url,
resource,
containers,
container: currentContainer,
mode: action,
protocol,
showLast
});
},
openSocket({ commit, state }, payload) {
commit('socketOpened', { ...payload });
const socket = new WebSocket(payload.url, payload.protocol);
socket.onmessage = (e) => {
decodeMsg(socket.protocol, e.data).then((message) => {
commit('addBacklog', { log: message });
})
.catch(err => console.error(err));
};
socket.onopen = () => {
state.toSend.forEach(msg => socket.send(msg));
commit('socketOpened', { socket, ...payload });
};
socket.onclose = (msg) => {
console.log('socket closed: ', msg);
};
},
};
export const mutations = {
closeModal(state) {
state.mode = null;
},
socketOpened(state, payload) {
for (const prop in payload) {
state[prop] = payload[prop];
}
},
addBacklog(state, payload) {
state.backlog.push(payload.log);
},
clearBacklog(state) {
state.backlog = [];
},
sendInput(state, payload) {
if (state.socket.readyState === 1) {
state.socket.send(0 + base64Encode(payload.input));
} else {
state.toSend.push(0 + base64Encode(payload.input));
}
}
};
const decodeMsg = (protocol, msg) => {
return new Promise((resolve, reject) => {
if (protocol === 'base64.binary.k8s.io') {
resolve(base64Decode(msg));
} else {
const type = msg[0];
const message = base64Decode(msg.slice(1));
if (type === '2') {
reject(message);
}
resolve(message);
}
});
};

View File

@ -11399,6 +11399,11 @@ vue-hot-reload-api@^2.3.0:
resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2"
integrity sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==
vue-js-modal@^1.3.31:
version "1.3.31"
resolved "https://registry.yarnpkg.com/vue-js-modal/-/vue-js-modal-1.3.31.tgz#fdece823d4f2816c8b1075c1fd8f667df11f5a42"
integrity sha512-gwt2904sWbMUuUcHwKQ510IEs4G7S3bqVWLYeTOc2eEyWMmmnT9UmojDsXIexFnPVM7cZTua37z3Jm/h0i0y8Q==
vue-loader@^15.7.1:
version "15.7.1"
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-15.7.1.tgz#6ccacd4122aa80f69baaac08ff295a62e3aefcfd"
@ -11854,6 +11859,16 @@ xtend@^4.0.0, xtend@~4.0.1:
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
xterm-addon-fit@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.2.1.tgz#353f43921eb78e3f9ad3f3afbb14e7ac183ca738"
integrity sha512-BlR57O3t1/bmVcnS81bn9ZnNf+GiGNbeXdNUKSBa9tKEwNUMcU3S+KFLIRv7rm1Ty0D5pMOu0vbz/RDorKRwKQ==
xterm@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.0.2.tgz#c6a1b9586c0786627625e2ee9e78ad519dbc8c99"
integrity sha512-NIr11b6C782TZznU8e6K/IMfmwlWMWRI6ba9GEDG9uX25SadkpjoMnzvxOS0Z/15sfrbn0rghPiarGDmmP0uhQ==
xxhashjs@^0.2.1:
version "0.2.2"
resolved "https://registry.yarnpkg.com/xxhashjs/-/xxhashjs-0.2.2.tgz#8a6251567621a1c46a5ae204da0249c7f8caa9d8"