Dialogs: bypassed component-based dialogs entirely — inlined modals directly in index.vue with inline style fallbacks for z-index/colors. If button clicks work, we see the modal. Period. Profile 500: created UpdateProfileDto with class-validator decorators so ValidationPipe processes it correctly. Added error logging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
267 lines
10 KiB
Vue
267 lines
10 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
|
|
}
|
|
|
|
// Inline modal state (bypasses component issues)
|
|
const inlineHost = ref({ name: '', hostname: '', port: 22, protocol: 'ssh' as 'ssh' | 'rdp' })
|
|
|
|
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 createHostInline() {
|
|
if (!inlineHost.value.name || !inlineHost.value.hostname) return
|
|
await connections.createHost({
|
|
name: inlineHost.value.name,
|
|
hostname: inlineHost.value.hostname,
|
|
port: inlineHost.value.port,
|
|
protocol: inlineHost.value.protocol,
|
|
})
|
|
showHostDialog.value = false
|
|
inlineHost.value = { name: '', hostname: '', port: 22, protocol: 'ssh' }
|
|
}
|
|
|
|
// 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>
|
|
|
|
<!-- Debug: inline test modal -->
|
|
<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' : '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">
|
|
<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 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="createHostInline()" class="px-4 py-2 text-sm bg-sky-600 hover:bg-sky-700 text-white rounded">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|