feat: quick connect, search, recent connections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
19183ee546
commit
8546824b97
46
frontend/components/connections/QuickConnect.vue
Normal file
46
frontend/components/connections/QuickConnect.vue
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const input = ref('')
|
||||||
|
const protocol = ref<'ssh' | 'rdp'>('ssh')
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
connect: [{ hostname: string; port: number; username: string; protocol: 'ssh' | 'rdp' }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleConnect() {
|
||||||
|
const raw = input.value.trim()
|
||||||
|
if (!raw) return
|
||||||
|
|
||||||
|
// Parse user@hostname:port format
|
||||||
|
let username = ''
|
||||||
|
let hostname = raw
|
||||||
|
let port = protocol.value === 'rdp' ? 3389 : 22
|
||||||
|
|
||||||
|
if (hostname.includes('@')) {
|
||||||
|
[username, hostname] = hostname.split('@')
|
||||||
|
}
|
||||||
|
if (hostname.includes(':')) {
|
||||||
|
const parts = hostname.split(':')
|
||||||
|
hostname = parts[0]
|
||||||
|
port = parseInt(parts[1], 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('connect', { hostname, port, username, protocol: protocol.value })
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 bg-gray-800 border-b border-gray-700">
|
||||||
|
<select v-model="protocol" class="bg-gray-700 text-gray-300 text-xs rounded px-2 py-1.5 border-none">
|
||||||
|
<option value="ssh">SSH</option>
|
||||||
|
<option value="rdp">RDP</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-model="input"
|
||||||
|
@keydown.enter="handleConnect"
|
||||||
|
:placeholder="`user@hostname:${protocol === 'rdp' ? '3389' : '22'}`"
|
||||||
|
class="flex-1 bg-gray-900 text-white px-3 py-1.5 rounded text-sm border border-gray-700 focus:border-wraith-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button @click="handleConnect" class="text-sm text-wraith-400 hover:text-wraith-300 px-2">Connect</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -2,9 +2,13 @@
|
|||||||
import { useTerminal } from '~/composables/useTerminal'
|
import { useTerminal } from '~/composables/useTerminal'
|
||||||
|
|
||||||
const connections = useConnectionStore()
|
const connections = useConnectionStore()
|
||||||
|
const sessions = useSessionStore()
|
||||||
const showHostDialog = ref(false)
|
const showHostDialog = ref(false)
|
||||||
const editingHost = ref<any>(null)
|
const editingHost = ref<any>(null)
|
||||||
const showGroupDialog = ref(false)
|
const showGroupDialog = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const showSavePrompt = ref(false)
|
||||||
|
const pendingQuickConnect = ref<any>(null)
|
||||||
|
|
||||||
// Terminal composable for connect-on-click
|
// Terminal composable for connect-on-click
|
||||||
const { createTerminal, connectToHost } = useTerminal()
|
const { createTerminal, connectToHost } = useTerminal()
|
||||||
@ -25,15 +29,9 @@ function openEditHost(host: any) {
|
|||||||
|
|
||||||
function connectHost(host: any) {
|
function connectHost(host: any) {
|
||||||
if (host.protocol !== 'ssh') {
|
if (host.protocol !== 'ssh') {
|
||||||
// RDP support in Phase 3
|
// RDP handled via quick connect for now
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// We connect via useTerminal — TerminalInstance will handle the actual mount
|
|
||||||
// Here we just trigger SessionContainer to render a new TerminalInstance
|
|
||||||
// The terminal composable is invoked inside TerminalInstance on mount
|
|
||||||
// We signal the session store directly to open a pending session slot
|
|
||||||
// TerminalInstance picks up the hostId prop and opens the WS connection
|
|
||||||
const sessions = useSessionStore()
|
|
||||||
const pendingId = `pending-${Date.now()}`
|
const pendingId = `pending-${Date.now()}`
|
||||||
sessions.addSession({
|
sessions.addSession({
|
||||||
id: pendingId,
|
id: pendingId,
|
||||||
@ -44,10 +42,87 @@ function connectHost(host: any) {
|
|||||||
active: true,
|
active: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quick connect: launch a temporary session without saving
|
||||||
|
function handleQuickConnect(params: { hostname: string; port: number; username: string; protocol: 'ssh' | 'rdp' }) {
|
||||||
|
const sessionId = `quick-${Date.now()}`
|
||||||
|
const displayName = params.username
|
||||||
|
? `${params.username}@${params.hostname}`
|
||||||
|
: params.hostname
|
||||||
|
|
||||||
|
sessions.addSession({
|
||||||
|
id: sessionId,
|
||||||
|
hostId: null,
|
||||||
|
hostName: displayName,
|
||||||
|
hostname: params.hostname,
|
||||||
|
port: params.port,
|
||||||
|
username: params.username,
|
||||||
|
protocol: params.protocol,
|
||||||
|
color: null,
|
||||||
|
active: true,
|
||||||
|
isTemporary: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
pendingQuickConnect.value = params
|
||||||
|
showSavePrompt.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a quick-connect as a permanent host
|
||||||
|
function saveQuickConnectHost() {
|
||||||
|
if (!pendingQuickConnect.value) return
|
||||||
|
const p = pendingQuickConnect.value
|
||||||
|
editingHost.value = {
|
||||||
|
hostname: p.hostname,
|
||||||
|
port: p.port,
|
||||||
|
protocol: p.protocol,
|
||||||
|
name: p.hostname,
|
||||||
|
}
|
||||||
|
showSavePrompt.value = false
|
||||||
|
pendingQuickConnect.value = null
|
||||||
|
showHostDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissSavePrompt() {
|
||||||
|
showSavePrompt.value = false
|
||||||
|
pendingQuickConnect.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side search filtering
|
||||||
|
const filteredHosts = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) return connections.hosts
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
return connections.hosts.filter((h: any) =>
|
||||||
|
h.name?.toLowerCase().includes(q) ||
|
||||||
|
h.hostname?.toLowerCase().includes(q) ||
|
||||||
|
(h.tags || []).some((t: string) => t.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recent connections — hosts sorted by lastConnectedAt, non-null only, top 5
|
||||||
|
const recentHosts = computed(() => {
|
||||||
|
return [...connections.hosts]
|
||||||
|
.filter((h: any) => h.lastConnectedAt)
|
||||||
|
.sort((a: any, b: any) =>
|
||||||
|
new Date(b.lastConnectedAt).getTime() - new Date(a.lastConnectedAt).getTime()
|
||||||
|
)
|
||||||
|
.slice(0, 5)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex w-full">
|
<div class="flex w-full flex-col">
|
||||||
|
<!-- Quick Connect bar -->
|
||||||
|
<QuickConnect @connect="handleQuickConnect" />
|
||||||
|
|
||||||
|
<!-- Save prompt toast -->
|
||||||
|
<div v-if="showSavePrompt"
|
||||||
|
class="fixed bottom-4 right-4 z-40 bg-gray-800 border border-gray-700 rounded-lg shadow-xl p-4 flex items-center gap-4 text-sm">
|
||||||
|
<span class="text-gray-300">Save this connection for later?</span>
|
||||||
|
<button @click="saveQuickConnectHost" class="text-wraith-400 hover:text-wraith-300 font-medium">Save</button>
|
||||||
|
<button @click="dismissSavePrompt" class="text-gray-500 hover:text-gray-300">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<!-- Sidebar: Group tree -->
|
<!-- Sidebar: Group tree -->
|
||||||
<aside class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col overflow-y-auto shrink-0">
|
<aside class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col overflow-y-auto shrink-0">
|
||||||
<div class="p-3 flex items-center justify-between">
|
<div class="p-3 flex items-center justify-between">
|
||||||
@ -60,11 +135,46 @@ function connectHost(host: any) {
|
|||||||
<HostTree :groups="connections.groups" @select-host="openEditHost" @new-host="openNewHost" />
|
<HostTree :groups="connections.groups" @select-host="openEditHost" @new-host="openNewHost" />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main: host list -->
|
<!-- Main: host grid with search + recent -->
|
||||||
<main class="flex-1 p-4 overflow-y-auto">
|
<main class="flex-1 p-4 overflow-y-auto">
|
||||||
|
<!-- Search bar -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search hosts by name, hostname, or tag..."
|
||||||
|
class="w-full bg-gray-800 text-white px-3 py-2 rounded border border-gray-700 focus:border-wraith-500 focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent connections (only when not searching) -->
|
||||||
|
<div v-if="!searchQuery && recentHosts.length > 0" class="mb-6">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Recent</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="host in recentHosts"
|
||||||
|
:key="host.id"
|
||||||
|
@click="connectHost(host)"
|
||||||
|
class="flex items-center gap-2 p-2 bg-gray-800/70 hover:bg-gray-700 rounded border border-gray-700/50 text-left"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 rounded-full shrink-0 bg-wraith-500" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm text-white truncate">{{ host.name }}</p>
|
||||||
|
<p class="text-xs text-gray-500 truncate">{{ host.hostname }}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All hosts grid -->
|
||||||
|
<div>
|
||||||
|
<h3 v-if="!searchQuery && recentHosts.length > 0"
|
||||||
|
class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||||
|
All Hosts
|
||||||
|
</h3>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
<HostCard
|
<HostCard
|
||||||
v-for="host in connections.hosts"
|
v-for="host in filteredHosts"
|
||||||
:key="host.id"
|
:key="host.id"
|
||||||
:host="host"
|
:host="host"
|
||||||
@connect="connectHost(host)"
|
@connect="connectHost(host)"
|
||||||
@ -72,10 +182,15 @@ function connectHost(host: any) {
|
|||||||
@delete="connections.deleteHost(host.id)"
|
@delete="connections.deleteHost(host.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!connections.hosts.length && !connections.loading" class="text-gray-600 text-center mt-12">
|
<p v-if="filteredHosts.length === 0 && searchQuery" class="text-gray-600 text-center mt-12">
|
||||||
|
No hosts match "{{ searchQuery }}"
|
||||||
|
</p>
|
||||||
|
<p v-if="!connections.hosts.length && !connections.loading && !searchQuery" class="text-gray-600 text-center mt-12">
|
||||||
No hosts yet. Click "+ Host" to add your first connection.
|
No hosts yet. Click "+ Host" to add your first connection.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Dialogs -->
|
<!-- Dialogs -->
|
||||||
<HostEditDialog v-model:visible="showHostDialog" :host="editingHost" @saved="connections.fetchHosts()" />
|
<HostEditDialog v-model:visible="showHostDialog" :host="editingHost" @saved="connections.fetchHosts()" />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user