Add pkg/harvester components + shell portings - 1

Signed-off-by: Francesco Torchia <francesco.torchia@suse.com>
This commit is contained in:
Francesco Torchia
2024-10-23 17:00:46 +02:00
parent f8408469f7
commit 4f2688f6ab
265 changed files with 42316 additions and 82 deletions
@@ -0,0 +1,149 @@
<template>
<div>
<div v-if="connected && disconnected">
<main class="main-layout error">
<div class="text-center">
<BrandImage file-name="error-desert-landscape.svg" width="900" height="300" />
<h1>
{{ t('generic.notification.title.warning') }}
</h1>
<h2 class="text-secondary mt-20">
{{ t('vncConsole.error.message') }}
</h2>
</div>
</main>
</div>
<div v-if="reconnecting">
<main class="main-layout">
<div class="text-center">
<h2 class="text-secondary mt-20">
{{ t('vncConsole.reconnecting.message') }}:{{ retryTimes }} of {{ maximumRetryTimes }}
</h2>
</div>
</main>
</div>
<div
ref="view"
/>
</div>
</template>
<script>
import RFB from '@novnc/novnc/core/rfb';
import BrandImage from '@shell/components/BrandImage';
export default {
props: {
url: {
type: String,
default: ''
}
},
components: { BrandImage },
data() {
return {
rfb: null,
connected: false,
disconnected: false,
reconnectDelay: 3000,
reconnecting: false,
maximumRetryTimes: 10,
retryTimes: 0,
setTimeout: null,
};
},
mounted() {
this.$nextTick(() => {
this.connect();
});
},
beforeDestroy() {
this.clearTimeout();
},
methods: {
connect() {
const rfb = new RFB(this.$refs.view, this.url);
rfb.addEventListener('connect', () => {
this.clearTimeout();
this.connected = true;
this.retryTimes = 0;
this.reconnecting = false;
});
rfb.addEventListener('disconnect', (e) => {
this.clearTimeout();
this.disconnected = true;
this.rfb = null;
this.reconnect();
});
this.rfb = rfb;
},
reconnect() {
if (this.retryTimes >= this.maximumRetryTimes) {
this.reconnecting = false;
this.connected = true;
this.disconnected = true;
return;
}
this.retryTimes += 1;
this.reconnecting = true;
this.connected = false;
this.disconnected = false;
this.setTimeout = setTimeout(() => {
this.connect();
}, this.reconnectDelay);
},
clearTimeout() {
if (this.setTimeout) {
clearTimeout(this.setTimeout);
}
},
disconnect() {
this.rfb.disconnect();
},
ctrlAltDelete() {
this.rfb.sendCtrlAltDel();
},
sendKey(keysym, code, down) {
this.rfb.sendKey(keysym, code, down);
}
}
};
</script>
<style lang="scss" scoped>
.error {
overflow: hidden;
.row {
align-items: center;
}
h1 {
font-size: 5rem;
}
.desert-landscape {
img {
max-width: 100%;
}
}
}
</style>
@@ -0,0 +1,245 @@
<script>
import { STEVE } from '@shell/config/types';
import Banner from '@components/Banner/Banner.vue';
import AsyncButton from '@shell/components/AsyncButton';
import ModalWithCard from '@shell/components/ModalWithCard';
const PREFERED_SHORTCUT_KEYS = 'prefered-shortcut-keys';
export default {
components: {
ModalWithCard, Banner, AsyncButton
},
data() {
return {
keysRecord: [],
addedShortcutKeys: [],
preferredShortcutKeys: [],
isRecording: false,
};
},
computed: {
savedShortcutKeys() {
const preference = this.$store.getters['management/all'](STEVE.PREFERENCE);
const preferedShortcutKeys = preference?.[0]?.data?.[PREFERED_SHORTCUT_KEYS];
let out = [];
if (!preferedShortcutKeys) {
return out;
}
try {
out = JSON.parse(preferedShortcutKeys);
} catch (err) {
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: this.t('harvester.virtualMachine.detail.console.customShortcutKeys') }),
err,
}, { root: true });
}
return out;
},
displayedKeys() {
const out = this.addedShortcutKeys.concat(this.preferredShortcutKeys).map((item) => {
const out = item.map(K => ` <code>${ K.key.charAt(0).toUpperCase() + K.key.slice(1) }</code>`);
return out.join(',');
});
return out;
},
recordButton() {
if (this.isRecording) {
return 'harvester.virtualMachine.detail.console.record.stop';
}
return 'harvester.virtualMachine.detail.console.record.start';
},
keysRecordFormat() {
if (!this.isRecording && this.keysRecord.length === 0) {
return this.t('harvester.virtualMachine.detail.console.record.tips');
}
const out = this.keysRecord.map(item => ` <code>${ item.key.charAt(0).toUpperCase() + item.key.slice(1) }</code>`);
return `Keys: ${ out.join(',') }`;
},
canAdd() {
const hasRecord = this.keysRecord.length > 0;
let validationList = [].concat(this.preferredShortcutKeys, this.addedShortcutKeys);
if (!hasRecord) {
return false;
}
validationList.push(this.keysRecord);
validationList = validationList.map((item) => {
const out = item.map(K => K.key);
return out.join(',');
});
return validationList.length === new Set(validationList).size;
},
},
watch: {
savedShortcutKeys: {
handler() {
this.preferredShortcutKeys = [].concat(this.savedShortcutKeys) || [];
},
immediate: true
},
},
methods: {
show() {
this.$refs.recordShortcutKeys.open();
},
closeRecordingModal() {
window.removeEventListener('keydown', this.handleShortcut);
this.$emit('close');
},
toggleRecording() {
this.isRecording = !this.isRecording;
if (this.isRecording) {
this.keysRecord = [];
window.addEventListener('keydown', this.handleShortcut);
} else {
window.removeEventListener('keydown', this.handleShortcut);
}
},
handleShortcut(event) {
event.preventDefault();
const {
key, keyCode, code, location, charCode
} = event;
this.keysRecord.push({
key, keyCode, code, location, charCode
});
},
addShortcutKey() {
this.addedShortcutKeys.push([].concat(this.keysRecord));
},
removeKey(keys) {
const key = keys.replace(/(\s*)<code>|<\/code>/g, '').replace(/\s*,\s*/g, ',');
this.addedShortcutKeys = this.addedShortcutKeys.filter((item) => {
const formatkey = item.map(K => K.key.charAt(0).toUpperCase() + K.key.slice(1)).join(',');
return formatkey !== key;
});
this.preferredShortcutKeys = this.preferredShortcutKeys.filter((item) => {
const formatkey = item.map(K => K.key.charAt(0).toUpperCase() + K.key.slice(1)).join(',');
return formatkey !== key;
});
},
async saveKeys(buttonCb) {
const out = [].concat(this.preferredShortcutKeys, this.addedShortcutKeys);
const preference = this.$store.getters['management/all'](STEVE.PREFERENCE)?.[0];
try {
this.$set(preference.data, PREFERED_SHORTCUT_KEYS, JSON.stringify(out));
await preference.save();
this.closeRecordingModal();
buttonCb(true);
} catch (err) {
buttonCb(false);
}
},
}
};
</script>
<template>
<ModalWithCard ref="recordShortcutKeys" name="recordShortcutKeys" :width="550">
<template #title>
<t k="harvester.virtualMachine.detail.console.customShortcutKeys" />
</template>
<template #content>
<div class="row">
<div class="col span-12">
<Banner color="info">
<span v-clean-html="keysRecordFormat"></span>
</Banner>
</div>
</div>
<div class="row">
<div class="col span-12">
<button class="btn bg-primary" @click="toggleRecording">
<t :k="recordButton" />
<i class="icon icon-fw" :class="isRecording ? 'icon-dot-open' : 'icon-dot'" />
</button>
<button :disabled="!canAdd" class="btn bg-primary" @click="addShortcutKey">
<t k="generic.add" />
</button>
</div>
</div>
<hr>
<div class="displayed-keys mt-20">
<h4
v-clean-html="t('harvester.virtualMachine.detail.console.record.preferredKeys')"
class="text-default-text"
/>
<div class="displayed-banners">
<Banner v-for="(keys,index) in displayedKeys" :key="index" color="info" :closable="true" @close="removeKey(keys)">
<span v-clean-html="keys"></span>
</Banner>
</div>
</div>
</template>
<template #footer>
<div class="actions">
<button class="btn role-secondary mr-20" @click.prevent="closeRecordingModal">
<t k="generic.close" />
</button>
<AsyncButton
mode="done"
@click="saveKeys"
/>
</div>
</template>
</ModalWithCard>
</template>
<style lang="scss" scoped>
.displayed-keys {
.banner {
margin: 0;
}
}
.displayed-banners {
max-height: 155px;
overflow: auto;
}
.actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,89 @@
<script>
export default {
name: 'NovncConsoleItem',
props: {
items: {
type: Object,
required: true,
default: () => {
return {};
}
},
path: {
type: Array,
required: true,
default: () => {
return [];
}
},
pos: {
type: Number,
required: true,
default: 0,
}
},
methods: {
keysDown(key, pos) {
this.addKeys({ key, pos });
this.$emit('send-keys');
},
addKeys({ key, pos }) {
this.$emit('update', { key, pos });
},
sendKeys() {
this.$emit('send-keys');
},
getOpenStatus(key, pos) {
return this.path[pos] === key;
}
}
};
</script>
<template>
<ul class="list-unstyled dropdown combination-keys__container">
<li v-for="(item, key) in items" :key="key">
<v-popover
v-if="!!item.keys"
placement="right-start"
trigger="click"
:container="false"
>
<span :class="{ open: getOpenStatus(key, pos) }" class="p-10 hand" @click="addKeys({ key, pos })">{{ item.label }}</span>
<template v-slot:popover>
<novnc-console-item :items="item.keys" :path="path" :pos="pos+1" @update="addKeys" @send-keys="sendKeys" />
</template>
</v-popover>
<span v-else class="p-10 hand" @click="keysDown(key, pos)">{{ item.label }}</span>
</li>
</ul>
</template>
<style lang="scss" scoped>
.combination-keys__container {
max-width: 60px;
DIV, SPAN {
display: block;
text-align: center;
}
SPAN {
border-radius: 3px;
&:hover, &.open {
color: var(--primary-hover-text);
background: var(--primary-hover-bg);
}
}
}
</style>
@@ -0,0 +1,348 @@
<script>
import { STEVE } from '@shell/config/types';
import { escapeHtml } from '@shell/utils/string';
import { allHash } from '@shell/utils/promise';
import KeyTable from '@novnc/novnc/core/input/keysym';
import * as KeyboardUtil from '@novnc/novnc/core/input/util';
import NovncConsole from './NovncConsole';
import NovncConsoleItem from './NovncConsoleItem';
import NovncConsoleCustomKeys from './NovncConsoleCustomKeys';
import { HCI } from '../../types';
const PREFERED_SHORTCUT_KEYS = 'prefered-shortcut-keys';
const SHORT_KEYS = {
ControlLeft: {
label: 'Ctrl',
value: KeyTable.XK_Control_L,
},
AltLeft: {
label: 'Alt',
value: KeyTable.XK_Alt_L,
}
};
const FUNCTION_KEYS = {
Delete: {
label: 'Del',
value: KeyTable.XK_Delete,
},
PrintScreen: {
label: 'Print Screen',
value: KeyTable.XK_Print,
},
};
const NORMAL_KEYS = {
KeyN: {
label: 'N',
value: KeyTable.XK_n,
},
KeyT: {
label: 'T',
value: KeyTable.XK_t,
},
KeyW: {
label: 'W',
value: KeyTable.XK_w,
},
KeyY: {
label: 'Y',
value: KeyTable.XK_y,
},
};
const F_KEYS = {
F1: {
label: 'F1',
value: KeyTable.XK_F1,
},
F2: {
label: 'F2',
value: KeyTable.XK_F2,
},
F3: {
label: 'F3',
value: KeyTable.XK_F3,
},
F4: {
label: 'F4',
value: KeyTable.XK_F4,
},
F5: {
label: 'F5',
value: KeyTable.XK_F5,
},
F6: {
label: 'F6',
value: KeyTable.XK_F6,
},
F7: {
label: 'F7',
value: KeyTable.XK_F7,
},
F8: {
label: 'F8',
value: KeyTable.XK_F8,
},
F9: {
label: 'F9',
value: KeyTable.XK_F9,
},
F10: {
label: 'F10',
value: KeyTable.XK_F10,
},
F11: {
label: 'F11',
value: KeyTable.XK_F11,
},
F12: {
label: 'F12',
value: KeyTable.XK_F12,
},
};
export default {
components: {
NovncConsole, NovncConsoleItem, NovncConsoleCustomKeys
},
async fetch() {
const _hash = { vmResource: this.$store.dispatch('harvester/find', { type: HCI.VM, id: this.value.id }) };
const hash = await allHash(_hash);
this.vmResource = hash.vmResource;
},
props: {
value: {
type: Object,
required: true,
default: () => {
return {};
}
}
},
data() {
return {
keysRecord: [],
vmResource: {},
renderKeysModal: false,
currentUser: null,
hideCustomKeysBar: false,
};
},
computed: {
savedShortcutKeys() {
const preference = this.$store.getters['management/all'](STEVE.PREFERENCE);
const preferedShortcutKeys = preference?.[0]?.data?.[PREFERED_SHORTCUT_KEYS];
let out = [];
if (!preference?.[0]?.data) {
this.hideCustomKeysBar = true;
return out;
}
if (!preferedShortcutKeys) {
return out;
}
try {
out = JSON.parse(preferedShortcutKeys);
} catch (err) {
this.$store.dispatch('growl/fromError', {
title: this.t('generic.notification.title.error', { name: escapeHtml(this.value.metadata.name) }),
err,
}, { root: true });
}
return out;
},
isDown() {
return this.isEmpty(this.value);
},
url() {
const ip = `${ window.location.hostname }:${ window.location.port }`;
return `wss://${ ip }${ this.value?.getVMIApiPath }`;
},
allKeys() {
return {
...SHORT_KEYS,
...FUNCTION_KEYS,
...NORMAL_KEYS,
...F_KEYS,
};
},
keymap() {
const out = {
...SHORT_KEYS,
PrintScreen: FUNCTION_KEYS.PrintScreen,
...F_KEYS,
};
out.AltLeft.keys = { PrintScreen: FUNCTION_KEYS.PrintScreen, ...F_KEYS };
out.ControlLeft.keys = {
AltLeft: {
...Object.assign(SHORT_KEYS.AltLeft, {}),
keys: { Delete: FUNCTION_KEYS.Delete }
},
...NORMAL_KEYS,
};
return out;
},
hasSoftRebootAction() {
return !!this.vmResource?.actions?.softreboot;
},
preferredShortcutKeys() {
return (this.savedShortcutKeys || []).map((item) => {
return {
label: item.map(K => K.key.charAt(0).toUpperCase() + K.key.slice(1)).join('+'),
value: item
};
});
},
},
methods: {
isEmpty(o) {
return o !== undefined && Object.keys(o).length === 0;
},
close() {
this.$refs.novncConsole.disconnect();
},
update({ key, pos }) {
this.keysRecord.splice(pos, this.keysRecord.length - pos, key);
},
// Send function key, e.g. ALT + F
sendKeys() {
this.keysRecord.forEach((key) => {
this.$refs.novncConsole.sendKey(this.allKeys[key].value, key, true);
});
this.keysRecord.reverse().forEach((key) => {
this.$refs.novncConsole.sendKey(this.allKeys[key].value, key, false);
});
this.$refs.popover.isOpen = false;
this.keysRecord = [];
},
sendCustomKeys(keys) {
const keyList = [].concat(keys);
keyList.forEach((K) => {
this.$refs.novncConsole.sendKey(KeyboardUtil.getKeysym(K), KeyboardUtil.getKeycode(K), true);
});
keyList.reverse().forEach((K) => {
this.$refs.novncConsole.sendKey(KeyboardUtil.getKeysym(K), KeyboardUtil.getKeycode(K), false);
});
},
softReboot() {
this.vmResource.softrebootVM();
},
showKeysModal() {
this.renderKeysModal = true;
this.$nextTick(() => {
this.$refs.keysModal.show();
});
},
hideKeysModal() {
this.renderKeysModal = false;
},
}
};
</script>
<template>
<div id="app">
<div class="vm-console">
<div class="combination-keys">
<v-popover
ref="popover"
placement="top"
trigger="click"
:container="false"
@auto-hide="keysRecord = []"
>
<button class="btn btn-sm bg-primary">
{{ t("harvester.virtualMachine.detail.console.shortcutKeys") }}
</button>
<template v-slot:popover>
<novnc-console-item :items="keymap" :path="keysRecord" :pos="0" @update="update" @send-keys="sendKeys" />
</template>
</v-popover>
<button v-if="hasSoftRebootAction" class="btn btn-sm bg-primary" @click="softReboot">
{{ t("harvester.action.softreboot") }}
</button>
<v-popover
v-if="!hideCustomKeysBar"
ref="customKeyPopover"
placement="top"
trigger="click"
:container="false"
>
<button class="btn btn-sm bg-primary">
{{ t("harvester.virtualMachine.detail.console.customShortcutKeys") }}
</button>
<template v-slot:popover>
<div>
<button class="btn btn-sm bg-primary" @click="showKeysModal">
{{ t("harvester.virtualMachine.detail.console.management") }}
</button>
</div>
<hr>
<div v-for="(keys, index) in preferredShortcutKeys" :key="index" class="mb-5">
<button class="btn btn-sm bg-primary" @click="sendCustomKeys(keys.value)">
{{ keys.label }}
</button>
</div>
</template>
</v-popover>
<NovncConsoleCustomKeys v-if="renderKeysModal" ref="keysModal" :current-user="currentUser" @close="hideKeysModal" />
</div>
<NovncConsole v-if="url && !isDown" ref="novncConsole" :url="url" />
<p v-if="isDown">
{{ t("harvester.virtualMachine.detail.console.down") }}
</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.vm-console {
height: 100%;
display: grid;
grid-template-rows: 30px auto;
}
.combination-keys {
background: rgb(40, 40, 40);
}
</style>