- 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>
144 lines
4.7 KiB
Vue
144 lines
4.7 KiB
Vue
<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>
|