wraith/frontend/pages/index.vue
Vantz Stockwell aa457b54d4 fix: infinite remount loop — use stable key for session components
When replaceSession changed the session ID from pending-XXX to a
real UUID, Vue's :key="session.id" treated it as a new element,
destroyed and recreated TerminalInstance, which called connectToHost
again, got another UUID, replaced again — infinite loop.

Added a stable `key` field to sessions that never changes after
creation, used as the Vue :key instead of the mutable `id`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:51:31 -04:00

494 lines
20 KiB
Vue

<script setup lang="ts">
import { useTerminal } from '~/composables/useTerminal'
const connections = useConnectionStore()
const sessions = useSessionStore()
const vault = useVault()
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)
// Right sidebar: selected host details
const selectedHost = ref<any>(null)
const hostCredential = ref<any>(null)
const loadingCredential = ref(false)
// Credentials list for host modal dropdown
const allCredentials = ref<any[]>([])
const allSshKeys = ref<any[]>([])
// Terminal composable for connect-on-click
const { createTerminal, connectToHost } = useTerminal()
onMounted(async () => {
await Promise.all([connections.fetchHosts(), connections.fetchTree()])
})
async function openNewHost(groupId?: number) {
editingHost.value = groupId ? { groupId } : null
inlineHost.value = { name: '', hostname: '', port: 22, protocol: 'ssh', groupId: groupId || null, tags: '', credentialId: null }
// Load credentials for dropdown
try {
const [creds, keys] = await Promise.all([
vault.listCredentials() as Promise<any[]>,
vault.listKeys() as Promise<any[]>,
])
allCredentials.value = creds
allSshKeys.value = keys
} catch { /* ignore */ }
showHostDialog.value = true
}
async function openEditHost(host: any) {
editingHost.value = host
inlineHost.value = {
name: host.name || '',
hostname: host.hostname || '',
port: host.port || 22,
protocol: host.protocol || 'ssh',
groupId: host.groupId || host.group?.id || null,
tags: (host.tags || []).join(', '),
credentialId: host.credentialId || null,
}
try {
const [creds, keys] = await Promise.all([
vault.listCredentials() as Promise<any[]>,
vault.listKeys() as Promise<any[]>,
])
allCredentials.value = creds
allSshKeys.value = keys
} catch { /* ignore */ }
showHostDialog.value = true
}
// Select a host → show right sidebar details
async function selectHost(host: any) {
selectedHost.value = host
hostCredential.value = null
if (host.credentialId) {
loadingCredential.value = true
try {
const creds = await vault.listCredentials() as any[]
hostCredential.value = creds.find((c: any) => c.id === host.credentialId) || null
} catch { /* ignore */ }
loadingCredential.value = false
}
}
function closeDetails() {
selectedHost.value = null
hostCredential.value = null
}
function connectHost(host: any) {
if (host.protocol !== 'ssh') {
return
}
const pendingId = `pending-${Date.now()}`
sessions.addSession({
key: pendingId,
id: pendingId,
hostId: host.id,
hostName: host.name,
protocol: 'ssh',
color: host.color,
active: true,
})
}
// Quick connect
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({
key: sessionId,
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
}
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
}
// Inline modal state
const inlineHost = ref({ name: '', hostname: '', port: 22, protocol: 'ssh' as 'ssh' | 'rdp', groupId: null as number | null, tags: '', credentialId: null as number | null })
async function createGroupInline() {
const nameEl = document.getElementById('grp-name') as HTMLInputElement
if (!nameEl?.value) return
await connections.createGroup({ name: nameEl.value })
showGroupDialog.value = false
}
async function saveHostInline() {
if (!inlineHost.value.name || !inlineHost.value.hostname) return
const tags = inlineHost.value.tags
.split(',')
.map(t => t.trim())
.filter(Boolean)
const payload = {
name: inlineHost.value.name,
hostname: inlineHost.value.hostname,
port: inlineHost.value.port,
protocol: inlineHost.value.protocol,
groupId: inlineHost.value.groupId,
credentialId: inlineHost.value.credentialId,
tags,
}
const wasEditingId = editingHost.value?.id
if (wasEditingId) {
await connections.updateHost(wasEditingId, payload)
} else {
await connections.createHost(payload)
}
showHostDialog.value = false
editingHost.value = null
inlineHost.value = { name: '', hostname: '', port: 22, protocol: 'ssh', groupId: null, tags: '', credentialId: null }
await connections.fetchTree()
// Refresh selected host details if it was the one we edited
if (wasEditingId && selectedHost.value?.id === wasEditingId) {
const updated = connections.hosts.find((h: any) => h.id === wasEditingId)
if (updated) selectedHost.value = updated
}
}
// 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
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)
})
async function deleteGroup(groupId: number) {
const group = connections.groups.find((g: any) => g.id === groupId)
const name = group?.name || 'this group'
if (!confirm(`Delete "${name}"? Hosts in this group will become ungrouped.`)) return
await connections.deleteGroup(groupId)
}
async function deleteSelectedHost() {
if (!selectedHost.value) return
if (!confirm(`Delete "${selectedHost.value.name}"?`)) return
await connections.deleteHost(selectedHost.value.id)
selectedHost.value = null
}
</script>
<template>
<div class="flex w-full flex-col">
<!-- Quick Connect bar -->
<ConnectionsQuickConnect @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">
<!-- Left 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>
<ConnectionsHostTree :groups="connections.groups" @select-host="selectHost" @new-host="openNewHost" @delete-group="deleteGroup" />
</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 -->
<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">
<ConnectionsHostCard
v-for="host in filteredHosts"
:key="host.id"
:host="host"
:selected="selectedHost?.id === host.id"
@click.stop="selectHost(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>
<!-- Right Sidebar: Host Details -->
<aside
v-if="selectedHost"
class="w-80 bg-gray-900 border-l border-gray-800 flex flex-col overflow-y-auto shrink-0"
>
<!-- Header -->
<div class="p-4 border-b border-gray-800 flex items-center justify-between">
<h3 class="text-sm font-semibold text-white">Host Details</h3>
<button @click="closeDetails" class="text-gray-500 hover:text-gray-300 text-lg leading-none">&times;</button>
</div>
<div class="p-4 space-y-5 flex-1">
<!-- Host name + protocol badge -->
<div>
<h4 class="text-lg font-bold text-white">{{ selectedHost.name }}</h4>
<span
class="text-xs font-medium px-2 py-0.5 rounded mt-1 inline-block"
:class="selectedHost.protocol === 'rdp'
? 'bg-purple-900/50 text-purple-300 border border-purple-800'
: 'bg-wraith-900/50 text-wraith-300 border border-wraith-800'"
>{{ selectedHost.protocol.toUpperCase() }}</span>
</div>
<!-- Address -->
<div>
<label class="text-xs text-gray-500 uppercase tracking-wider">Address</label>
<p class="text-sm text-white font-mono mt-0.5">{{ selectedHost.hostname }}</p>
</div>
<!-- Port -->
<div>
<label class="text-xs text-gray-500 uppercase tracking-wider">Port</label>
<p class="text-sm text-white font-mono mt-0.5">{{ selectedHost.port }}</p>
</div>
<!-- Group -->
<div v-if="selectedHost.group">
<label class="text-xs text-gray-500 uppercase tracking-wider">Group</label>
<p class="text-sm text-gray-300 mt-0.5">{{ selectedHost.group.name }}</p>
</div>
<!-- Credential -->
<div>
<label class="text-xs text-gray-500 uppercase tracking-wider">Credential</label>
<div v-if="loadingCredential" class="text-xs text-gray-600 mt-0.5">Loading...</div>
<div v-else-if="hostCredential" class="mt-1 bg-gray-800 rounded p-2 border border-gray-700">
<p class="text-sm text-white">{{ hostCredential.name }}</p>
<p class="text-xs text-gray-400">{{ hostCredential.username || 'No username' }}</p>
<span
class="text-[10px] mt-1 inline-block px-1.5 py-0.5 rounded"
:class="hostCredential.type === 'ssh_key' ? 'bg-purple-900/40 text-purple-300' : 'bg-blue-900/40 text-blue-300'"
>{{ hostCredential.type === 'ssh_key' ? 'SSH Key' : 'Password' }}</span>
</div>
<p v-else class="text-xs text-gray-600 mt-0.5 italic">None assigned</p>
</div>
<!-- Tags -->
<div>
<label class="text-xs text-gray-500 uppercase tracking-wider">Tags</label>
<div v-if="selectedHost.tags?.length" class="flex flex-wrap gap-1 mt-1">
<span
v-for="tag in selectedHost.tags"
:key="tag"
class="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded border border-gray-700"
>{{ tag }}</span>
</div>
<p v-else class="text-xs text-gray-600 mt-0.5 italic">No tags</p>
</div>
<!-- Color -->
<div v-if="selectedHost.color">
<label class="text-xs text-gray-500 uppercase tracking-wider">Color</label>
<div class="flex items-center gap-2 mt-1">
<span class="w-4 h-4 rounded-full border border-gray-600" :style="{ backgroundColor: selectedHost.color }" />
<span class="text-xs text-gray-400 font-mono">{{ selectedHost.color }}</span>
</div>
</div>
<!-- Notes -->
<div v-if="selectedHost.notes">
<label class="text-xs text-gray-500 uppercase tracking-wider">Notes</label>
<p class="text-sm text-gray-400 mt-0.5 whitespace-pre-wrap">{{ selectedHost.notes }}</p>
</div>
<!-- Last Connected -->
<div>
<label class="text-xs text-gray-500 uppercase tracking-wider">Last Connected</label>
<p class="text-xs text-gray-400 mt-0.5">
{{ selectedHost.lastConnectedAt ? new Date(selectedHost.lastConnectedAt).toLocaleString() : 'Never' }}
</p>
</div>
</div>
<!-- Action buttons -->
<div class="p-4 border-t border-gray-800 space-y-2">
<button
@click="connectHost(selectedHost)"
class="w-full py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm font-medium"
>Connect</button>
<div class="flex gap-2">
<button
@click="openEditHost(selectedHost)"
class="flex-1 py-1.5 bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white rounded text-sm border border-gray-700"
>Edit</button>
<button
@click="deleteSelectedHost"
class="flex-1 py-1.5 bg-gray-800 hover:bg-red-900/50 text-gray-400 hover:text-red-300 rounded text-sm border border-gray-700 hover:border-red-800"
>Delete</button>
</div>
</div>
</aside>
</div>
<!-- Inline modal for New Group / New Host -->
<div v-if="showGroupDialog || showHostDialog" class="fixed inset-0 z-[9999] flex items-center justify-center" style="z-index: 99999;">
<div class="absolute inset-0 bg-black bg-opacity-70" @click="showGroupDialog = false; showHostDialog = false" />
<div class="relative bg-gray-900 border border-gray-700 rounded-lg shadow-2xl p-6 w-96" style="background: #1a1a2e; border: 2px solid #e94560;">
<h3 class="text-lg font-bold text-white mb-4">{{ showGroupDialog ? 'New Group' : (editingHost?.id ? 'Edit Host' : 'New Host') }}</h3>
<div v-if="showGroupDialog" class="space-y-3">
<div>
<label class="block text-sm text-gray-400 mb-1">Group Name</label>
<input id="grp-name" type="text" placeholder="Production Servers"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white" />
</div>
<div class="flex justify-end gap-2 pt-2">
<button @click="showGroupDialog = false" class="px-4 py-2 text-sm text-gray-400 hover:text-white">Cancel</button>
<button @click="createGroupInline()" class="px-4 py-2 text-sm bg-sky-600 hover:bg-sky-700 text-white rounded">Save</button>
</div>
</div>
<div v-else class="space-y-3">
<!-- Group selector -->
<div>
<label class="block text-sm text-gray-400 mb-1">Group</label>
<select v-model="inlineHost.groupId" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white">
<option :value="null">— Ungrouped —</option>
<option v-for="g in connections.groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Name</label>
<input v-model="inlineHost.name" type="text" placeholder="My Server"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white" />
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1">Hostname / IP</label>
<input v-model="inlineHost.hostname" type="text" placeholder="192.168.1.1"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white" />
</div>
<div class="w-24">
<label class="block text-sm text-gray-400 mb-1">Port</label>
<input v-model.number="inlineHost.port" type="number"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white" />
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Protocol</label>
<select v-model="inlineHost.protocol" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white">
<option value="ssh">SSH</option>
<option value="rdp">RDP</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Credential</label>
<select v-model="inlineHost.credentialId" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white">
<option :value="null"> None </option>
<option v-for="cred in allCredentials" :key="cred.id" :value="cred.id">
{{ cred.name }} ({{ cred.username || cred.type }})
</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Tags <span class="text-gray-600">(comma separated)</span></label>
<input v-model="inlineHost.tags" type="text" placeholder="ssh, prod, web"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white" />
</div>
<div class="flex justify-end gap-2 pt-2">
<button @click="showHostDialog = false" class="px-4 py-2 text-sm text-gray-400 hover:text-white">Cancel</button>
<button @click="saveHostInline()" class="px-4 py-2 text-sm bg-sky-600 hover:bg-sky-700 text-white rounded">{{ editingHost?.id ? 'Update' : 'Save' }}</button>
</div>
</div>
</div>
</div>
</div>
</template>