wraith/frontend/components/connections/HostTree.vue
Vantz Stockwell 5abbffca9b feat(ui): add color accents across the connection manager
- Default protocol color strips on all host cards (wraith-blue for SSH, purple for RDP)
- Deterministic tag colors from 8-color palette (teal, amber, violet, rose, emerald, sky, orange, indigo)
- Last-connected recency coloring (green=today, amber=this week, gray=older)
- Section header dots (wraith-400 for Recent, gray for All Hosts)
- Active nav link highlighting (wraith-400)
- Group headers get subtle wraith-500 left border accent
- Tree host dots default to protocol color instead of gray
- Fixed rogue modal using hardcoded #1a1a2e/#e94560 — now uses design system
- Fixed sky-600 save buttons → wraith-600 for brand consistency
- Credential type badges: SSH Key=wraith, Password=amber (was purple/blue)
- Colored tags in right sidebar detail panel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:01:58 -04:00

144 lines
4.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
interface Host {
id: number
name: string
hostname: string
port: number
protocol: 'ssh' | 'rdp'
color: string | null
tags?: string[]
}
interface HostGroup {
id: number
name: string
parentId: number | null
children: HostGroup[]
hosts: Host[]
}
const props = defineProps<{
groups: HostGroup[]
}>()
const emit = defineEmits<{
(e: 'select-host', host: Host): void
(e: 'new-host', groupId?: number): void
(e: 'delete-group', groupId: number): void
}>()
const expanded = ref<Set<number>>(new Set())
function toggleGroup(id: number) {
if (expanded.value.has(id)) {
expanded.value.delete(id)
} else {
expanded.value.add(id)
}
}
function isExpanded(id: number) {
return expanded.value.has(id)
}
// Recursively count all hosts in a group and its children
function countHosts(group: HostGroup): number {
let count = group.hosts?.length || 0
for (const child of group.children || []) {
count += countHosts(child)
}
return count
}
// Tag color — same algorithm as HostCard
const TAG_COLORS = [
'bg-teal-900/40 text-teal-300',
'bg-amber-900/40 text-amber-300',
'bg-violet-900/40 text-violet-300',
'bg-rose-900/40 text-rose-300',
'bg-emerald-900/40 text-emerald-300',
'bg-sky-900/40 text-sky-300',
'bg-orange-900/40 text-orange-300',
'bg-indigo-900/40 text-indigo-300',
]
function tagColor(tag: string): string {
let hash = 0
for (let i = 0; i < tag.length; i++) hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]
}
</script>
<template>
<div class="flex-1 overflow-y-auto text-sm">
<!-- Groups -->
<template v-for="group in groups" :key="group.id">
<div>
<!-- Group header -->
<div
class="flex items-center gap-1 px-3 py-1.5 cursor-pointer hover:bg-gray-800 text-gray-400 hover:text-gray-200 select-none group/grp border-l-2 border-wraith-500/30"
@click="toggleGroup(group.id)"
>
<span class="text-xs w-3">{{ isExpanded(group.id) ? '▾' : '▸' }}</span>
<span class="font-medium truncate flex-1">{{ group.name }}</span>
<span class="text-xs text-gray-600 tabular-nums mr-1">{{ countHosts(group) }}</span>
<button
@click.stop="emit('delete-group', group.id)"
class="text-xs text-gray-600 hover:text-red-400 px-0.5 opacity-0 group-hover/grp:opacity-100 transition-opacity"
title="Delete group"
>×</button>
<button
@click.stop="emit('new-host', group.id)"
class="text-xs text-gray-600 hover:text-wraith-400 px-0.5 opacity-0 group-hover/grp:opacity-100 transition-opacity"
title="Add host to group"
>+</button>
</div>
<!-- Group children (hosts + sub-groups) -->
<div v-if="isExpanded(group.id)" class="pl-3">
<!-- Sub-groups recursively -->
<HostTree
v-if="group.children?.length"
:groups="group.children"
@select-host="(h) => emit('select-host', h)"
@new-host="(gid) => emit('new-host', gid)"
@delete-group="(gid) => emit('delete-group', gid)"
/>
<!-- Hosts in this group -->
<div
v-for="host in group.hosts"
:key="host.id"
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-gray-800 text-gray-300 hover:text-white group/host"
@click="emit('select-host', host)"
>
<span
class="w-2 h-2 rounded-full shrink-0"
:style="{ backgroundColor: host.color || (host.protocol === 'rdp' ? '#a855f7' : '#5c7cfa') }"
/>
<div class="min-w-0 flex-1">
<span class="truncate block">{{ host.name }}</span>
<div v-if="host.tags?.length" class="flex gap-1 mt-0.5 flex-wrap">
<span
v-for="tag in host.tags.slice(0, 3)"
:key="tag"
class="text-[10px] leading-tight px-1 py-px rounded"
:class="tagColor(tag)"
>{{ tag }}</span>
<span v-if="host.tags.length > 3" class="text-[10px] text-gray-600">+{{ host.tags.length - 3 }}</span>
</div>
</div>
<span
class="text-xs px-1 rounded shrink-0"
:class="host.protocol === 'rdp' ? 'text-purple-400 bg-purple-900/30' : 'text-wraith-400 bg-wraith-900/30'"
>{{ host.protocol.toUpperCase() }}</span>
</div>
</div>
</div>
</template>
<!-- Ungrouped hosts (shown at root level when no groups) -->
<div v-if="!groups.length" class="px-3 py-2 text-gray-600 text-xs">No groups yet</div>
</div>
</template>