wraith/frontend/pages/index.vue
Vantz Stockwell 8546824b97 feat: quick connect, search, recent connections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 17:32:18 -04:00

200 lines
6.9 KiB
Vue

<script setup lang="ts">
import { useTerminal } from '~/composables/useTerminal'
const connections = useConnectionStore()
const sessions = useSessionStore()
const showHostDialog = ref(false)
const editingHost = ref<any>(null)
const showGroupDialog = ref(false)
const searchQuery = ref('')
const showSavePrompt = ref(false)
const pendingQuickConnect = ref<any>(null)
// Terminal composable for connect-on-click
const { createTerminal, connectToHost } = useTerminal()
onMounted(async () => {
await Promise.all([connections.fetchHosts(), connections.fetchTree()])
})
function openNewHost(groupId?: number) {
editingHost.value = groupId ? { groupId } : null
showHostDialog.value = true
}
function openEditHost(host: any) {
editingHost.value = host
showHostDialog.value = true
}
function connectHost(host: any) {
if (host.protocol !== 'ssh') {
// RDP handled via quick connect for now
return
}
const pendingId = `pending-${Date.now()}`
sessions.addSession({
id: pendingId,
hostId: host.id,
hostName: host.name,
protocol: 'ssh',
color: host.color,
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>
<template>
<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 -->
<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">
<span class="text-sm font-medium text-gray-400">Connections</span>
<div class="flex gap-1">
<button @click="showGroupDialog = true" class="text-xs text-gray-500 hover:text-wraith-400" title="New Group">+ Group</button>
<button @click="openNewHost()" class="text-xs text-gray-500 hover:text-wraith-400" title="New Host">+ Host</button>
</div>
</div>
<HostTree :groups="connections.groups" @select-host="openEditHost" @new-host="openNewHost" />
</aside>
<!-- Main: host grid with search + recent -->
<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">
<HostCard
v-for="host in filteredHosts"
:key="host.id"
:host="host"
@connect="connectHost(host)"
@edit="openEditHost(host)"
@delete="connections.deleteHost(host.id)"
/>
</div>
<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.
</p>
</div>
</main>
</div>
<!-- Dialogs -->
<HostEditDialog v-model:visible="showHostDialog" :host="editingHost" @saved="connections.fetchHosts()" />
<GroupEditDialog v-model:visible="showGroupDialog" @saved="connections.fetchTree()" />
</div>
</template>