add ssh jump hosts table component

This commit is contained in:
Mohammad Azmi
2026-03-06 18:07:28 +07:00
parent 911447b69b
commit 0b78de3e27
4 changed files with 443 additions and 120 deletions

View File

@@ -44,7 +44,13 @@ import Vue from 'vue'
expanded() {
this.toggleContent = this.expanded
this.$emit('expanded', this.expanded)
}
},
toggleContent: {
handler() {
this.$emit('toggleContent', this.toggleContent)
},
immediate: true,
},
},
computed: {
toggleIcon() {

View File

@@ -1,8 +1,9 @@
<template>
<toggle-form-area
title="SSH Tunnel"
hide-toggle="true"
:expanded="config.sshEnabled"
@toggleContent="toggleContent = $event"
:expanded="true"
:initially-expanded="true"
>
<template v-slot:header>
<x-switch
@@ -17,6 +18,9 @@
<div>For the SSH tunnel to work, AllowTcpForwarding must be set to "yes" in your ssh server config.</div>
</div>
</div>
<ssh-jump-hosts-table v-if="toggleContent" :rows="rows" />
<div class="row gutter">
<div class="col s9 form-group">
<label for="sshHost">SSH Hostname</label>
@@ -35,24 +39,8 @@
/>
</div>
</div>
<div class="row gutter">
<div class="col s6 form-group">
<label for="bastionHost">Bastion Host (Jump Host)</label>
<masked-input
:value="config.sshBastionHost"
:privacyMode="privacyMode"
@input="val => config.sshBastionHost = val"
/>
</div>
<div class="col s3 form-group">
<label for="bastionHost">Bastion Port</label>
<masked-input
:value="config.sshBastionPort"
:privacyMode="privacyMode"
@input="val => config.sshBastionPort = val"
/>
</div>
<div class="col s3 form-group">
<div class="row">
<div class="col form-group">
<label for="sshKeepaliveInterval">
Keepalive Interval <i
class="material-icons"
@@ -81,71 +69,56 @@
{{ option.label }}
</option>
</select>
</div>
<div
v-if="config.sshMode === 'agent'"
class="agent flex-col"
>
<div class="form-group">
<label for="sshUsername">SSH Username</label>
<masked-input
:value="config.sshUsername"
:privacyMode="privacyMode"
@input="val => config.sshUsername = val"
/>
</div>
<div
class="alert alert-warning"
v-if="$config.isSnap"
>
<i class="material-icons">error_outline</i>
<div>
SSH Agent Forwarding is not possible with the Snap version of Beekeeper Studio due to the security model of Snap apps.
<external-link :href="enableSshLink">
Read more
</external-link>
<div class="ssh-agent-indicator" v-if="config.sshMode === 'agent'">
<div
class="error"
v-if="$config.isSnap"
>
<i class="material-icons">error_outline</i>
<div>
SSH Agent Forwarding is not possible with the Snap version of Beekeeper Studio due to the security model of Snap apps.
<external-link :href="enableSshLink">
Read more
</external-link>
</div>
</div>
<div
v-else-if="$config.sshAuthSock"
class="success"
>
<i class="material-icons">check</i>
<div>We found your SSH Agent. You're good to go!</div>
</div>
<div
v-else-if="$config.isWindows"
class="info"
>
<i class="material-icons-outlined">info</i>
<div>We didn't find a *nix ssh-agent running, so we'll attempt to use the PuTTY agent, pageant.</div>
</div>
<div
v-else
class="warning"
>
<i class="material-icons">error_outline</i>
<div>You don't seem to have an SSH agent running.</div>
</div>
</div>
<div
v-else-if="$config.sshAuthSock"
class="alert alert-success"
>
<i class="material-icons">check</i>
<div>We found your SSH Agent. You're good to go!</div>
</div>
<div
v-else-if="$config.isWindows"
class="alert alert-info"
>
<i class="material-icons-outlined">info</i>
<div>We didn't find a *nix ssh-agent running, so we'll attempt to use the PuTTY agent, pageant.</div>
</div>
<div
v-else
class="alert alert-warning"
>
<i class="material-icons">error_outline</i>
<div>You don't seem to have an SSH agent running.</div>
</div>
</div>
<div class="form-group">
<label for="sshUsername">SSH Username</label>
<masked-input
:value="config.sshUsername"
:privacyMode="privacyMode"
@input="val => config.sshUsername = val"
/>
</div>
<div
v-if="config.sshMode === 'keyfile'"
class="private-key gutter"
>
<div class="row">
<div class="col">
<div class="form-group">
<label for="sshUsername">SSH Username</label>
<masked-input
:value="config.sshUsername"
:privacyMode="privacyMode"
@input="val => config.sshUsername = val"
/>
</div>
</div>
</div>
<div
v-if="$config.isSnap && !$config.snapSshPlug"
class="row"
@@ -159,51 +132,55 @@
</div>
</div>
</div>
<div class="row gutter">
<div class="col s6 form-group">
<label for="sshKeyfile">Private Key File</label>
<file-picker
v-model="config.sshKeyfile"
editable
:show-hidden-files="true"
:default-path="filePickerDefaultPath"
/>
</div>
<div class="col s6 form-group">
<label for="sshKeyfilePassword">Key File PassPhrase <span class="hint">(Optional)</span></label>
<input
type="password"
class="form-control"
v-model="config.sshKeyfilePassword"
>
</div>
<div class="row form-group">
<label for="sshKeyfile">Private Key File</label>
<file-picker
v-model="config.sshKeyfile"
editable
:show-hidden-files="true"
:default-path="filePickerDefaultPath"
/>
</div>
<div class="row form-group">
<label for="sshKeyfilePassword">Key File PassPhrase <span class="hint">(Optional)</span></label>
<input
type="password"
class="form-control"
v-model="config.sshKeyfilePassword"
>
</div>
</div>
<div
v-if="config.sshMode === 'userpass'"
class="row gutter"
class="form-group"
>
<div class="col s6">
<div class="form-group">
<label for="sshUsername">SSH Username</label>
<masked-input
:value="config.sshUsername"
:privacyMode="privacyMode"
@input="val => config.sshUsername = val"
/>
</div>
<label for="sshPassword">SSH Password</label>
<input
class="form-control"
type="password"
v-model="config.sshPassword"
>
</div>
<div class="row gutter" v-if="false">
<div class="col s9 form-group">
<label for="bastionHost">Bastion Host (Jump Host)</label>
<masked-input
:value="config.sshBastionHost"
:privacyMode="privacyMode"
@input="val => config.sshBastionHost = val"
/>
</div>
<div class="col s6">
<div class="form-group">
<label for="sshPassword">SSH Password</label>
<input
class="form-control"
type="password"
v-model="config.sshPassword"
>
</div>
<div class="col s3 form-group">
<label for="bastionHost">Port</label>
<masked-input
:value="config.sshBastionPort"
:privacyMode="privacyMode"
@input="val => config.sshBastionPort = val"
/>
</div>
</div>
</template>
</toggle-form-area>
</template>
@@ -213,12 +190,14 @@ import ExternalLink from '@/components/common/ExternalLink.vue'
import ToggleFormArea from '../common/ToggleFormArea.vue'
import MaskedInput from '@/components/MaskedInput.vue'
import { mapState } from 'vuex'
import SshJumpHostsTable from '@/components/connection/SshJumpHostsTable.vue'
export default {
props: ['config'],
components: {
FilePicker, ExternalLink,
ToggleFormArea, MaskedInput
ToggleFormArea, MaskedInput,
SshJumpHostsTable,
},
computed: {
...mapState('settings', ['privacyMode']),
@@ -231,9 +210,23 @@ export default {
{ label: "Username & Password", mode: "userpass" },
{ label: "SSH Agent", mode: "agent" }
],
filePickerDefaultPath: window.main.join(platformInfo.homeDirectory, '.ssh')
filePickerDefaultPath: window.main.join(platformInfo.homeDirectory, '.ssh'),
toggleContent: false,
}
},
computed: {
rows() {
return [
{
host: `${this.config.sshHost ?? ''}:${this.config.sshPort ?? ''}`,
username: this.config.sshUsername ?? '',
auth: this.config.sshMode ?? '',
},
{ host: "localhost:3601", username: "azmy60", auth: "Password" },
{ host: "localhost:3601", username: "azmy60", auth: "Key File" },
];
},
},
methods: {
setMode(option) {
this.config.sshMode = option.mode
@@ -241,3 +234,30 @@ export default {
}
}
</script>
<style scoped lang="scss">
.ssh-agent-indicator > div {
display: flex;
font-size: 0.76em;
gap: 0.4em;
padding-top: 0.5em;
.material-icons, .material-icons-outlined {
font-size: 1.17em;
line-height: 0.9;
}
&.success {
color: var(--brand-success);
}
&.info {
color: var(--brand-info);
}
&.warning {
color: var(--brand-warning);
}
}
</style>

View File

@@ -0,0 +1,296 @@
<template>
<div class="ssh-table-container">
<div class="ssh-orders">
<div v-for="(_row, idx) in rows" :key="idx" class="host-bullet" :title="idx === rows.length - 1 ? 'Target Host' : `Jump Host #${idx + 1}`
" />
<div class="add-host-bullet">
<i class="material-icons">add_circle</i>
</div>
</div>
<div class="ssh-table-rows">
<div class="ssh-table" ref="sshTable" />
<button type="button" class="btn btn-flat add-host-btn">
Add Jump Host
</button>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { Tabulator } from "tabulator-tables";
export default Vue.extend({
props: {
rows: {
type: Array,
required: true,
},
},
data() {
return {
sshTable: null as Tabulator | null,
};
},
watch: {
async rows() {
if (!this.sshTable) {
return;
}
const selectedRowPosition =
this.sshTable.getSelectedRows()[0]?.getPosition() ?? 1;
await this.sshTable.setData(this.rows);
this.sshTable.selectRow(
this.sshTable.getRowFromPosition(selectedRowPosition)
);
},
},
methods: {
createTable() {
this.destroyTable();
const sshTable = new Tabulator(this.$refs.sshTable, {
layout: "fitColumns",
movableRows: true,
rowHeader: {
headerSort: false,
resizable: false,
minWidth: 30,
width: 30,
rowHandle: true,
formatter: "handle",
},
columnDefaults: {
headerSort: false,
resizable: false,
},
columns: [
{ title: "Host", field: "host", minWidth: 140 },
{ title: "Username", field: "username", minWidth: 72 },
{
title: "Auth",
field: "auth",
minWidth: 70,
formatter: "lookup",
formatterParams: {
agent: "Agent",
userpass: "Password",
keyfile: "Key File",
},
},
{
title: "",
cssClass: "action",
formatter(cell) {
const button = document.createElement("button");
button.type = "button";
button.classList.add("btn", "btn-fab");
const icon = document.createElement("i");
icon.classList.add("material-icons");
icon.innerText = "clear";
button.appendChild(icon);
button.onclick = () => {
console.log("hello ", cell.getRow().getData());
};
return button;
},
},
],
});
this.sshTable = sshTable;
sshTable.on("tableBuilt", async () => {
await sshTable.setData(this.rows);
sshTable.getRowFromPosition(1).select();
});
sshTable.on("rowClick", (_e, row) => {
if (row.isSelected()) {
return;
}
row
.getTable()
.getSelectedRows()
.forEach((r) => r.deselect());
row.select();
});
},
destroyTable() {
if (this.sshTable) {
this.sshTable.destroy();
this.sshTable = null;
}
},
},
mounted() {
this.createTable();
},
beforeDestroy() {
this.destroyTable();
},
});
</script>
<style lang="scss" scoped>
.ssh-table-container {
display: flex;
.ssh-table-rows {
flex-grow: 1;
}
}
.ssh-orders {
display: flex;
flex-direction: column;
// width: 1.5rem;
padding-top: calc(2.28rem + 3px);
gap: 3px;
margin-right: 0.75rem;
color: var(--text-lighter);
>* {
height: 2.14rem;
display: flex;
align-items: center;
position: relative;
&.host-bullet::before {
content: "";
width: 0.75em;
height: 0.75em;
background-color: currentColor;
border-radius: 9999px;
}
&.add-host-bullet {
position: relative;
.material-icons {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 1em;
}
}
&:not(:last-child)::after {
position: absolute;
inset-inline: 0;
margin-inline: auto;
bottom: -0.65rem;
display: flex;
align-items: center;
justify-content: center;
font-family: "Material Icons";
font-size: 1em;
content: "more_vert";
}
}
}
.btn.add-host-btn {
width: 100%;
padding-left: 2.5rem;
color: var(--text-dark);
font-size: 0.831rem;
font-weight: normal;
justify-content: flex-start;
border-radius: 5px;
border: none;
background-color: rgb(from var(--theme-base) r g b / 4%);
&:hover,
&:focus {
font-weight: normal;
}
}
.ssh-table ::v-deep {
&.tabulator {
.tabulator-header {
box-shadow: none;
.tabulator-col {
.tabulator-col-content {
padding-inline: 0.4rem;
}
.tabulator-col-title {
color: var(--text-lighter);
}
}
}
.tabulator-row {
background-color: rgb(from var(--theme-base) r g b / 5%);
}
}
.tabulator-row {
margin: 3px 0;
color: var(--text-dark);
border-radius: 5px;
font-size: 0.831rem;
&.tabulator-selected {
outline-offset: -1px;
outline: 1px solid var(--input-highlight);
}
.tabulator-row-header {
background-color: transparent;
}
.tabulator-cell {
&,
&.tabulator-row-header {
padding-inline: 0.4rem;
}
&:hover {
background-color: transparent;
}
&.action {
display: inline-flex;
align-items: center;
justify-content: flex-end;
padding: 0;
button {
margin: 0;
background-color: transparent;
&:hover .material-icons {
color: var(--text-dark);
}
.material-icons {
color: var(--text-lighter);
font-size: 1.3em;
}
}
}
.tabulator-row-handle-box {
width: auto;
display: flex;
height: 60%;
gap: 2px;
}
.tabulator-row-handle-bar {
height: 100%;
margin: 0;
width: 1px;
background-color: var(--border-color);
&:last-child {
display: none;
}
}
}
}
}
</style>

View File

@@ -152,8 +152,9 @@ class SSHConnection {
if (err) {
return reject(err)
}
const connection = await this.connect(this.options.endHost, undefined, stream)
return resolve(connection)
this.connect(this.options.endHost, this.options.endPort, stream)
.catch(reject)
.then(resolve);
})
})
}
@@ -165,7 +166,7 @@ class SSHConnection {
const options = {
host,
port,
port: port || 22,
username: this.options.username,
password: this.options.password,
privateKey: this.options.privateKey,