Files
nexus/frontend/src/components/dashboard/TeamNetwork.vue
T
developer b7b44494f0
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
fix(shadcn): isolate Nexus CSS vars with --nx- prefix + admin password reset endpoint
2026-06-11 10:06:58 +02:00

542 lines
14 KiB
Vue

<script setup lang="ts">
import { ref, computed, toRef, onMounted, onUnmounted } from 'vue'
import { Bot, Code2, Server, Shield, Search, Terminal } from '@lucide/vue'
import type { AgentNodeData } from '../../composables/useDashboardData'
import { useTeamNetworkSvg } from '../../composables/useTeamNetworkSvg'
const props = defineProps<{
agents: AgentNodeData[]
heroId?: string
activeAgents?: string[]
}>()
const emit = defineEmits<{
select: [id: string]
}>()
// ── Network ref ──
const networkRef = ref<HTMLDivElement | null>(null)
// ── Computed data ──
const heroId = computed(() => props.heroId ?? props.agents[0]?.id ?? '')
function isActive(id: string): boolean {
return props.activeAgents?.includes(id) ?? false
}
// ── SVG composable ──
const {
svgWidth,
svgHeight,
childAgents,
connectionPaths,
storePathRef,
storePulseRef,
storePulseRef2,
} = useTeamNetworkSvg(networkRef, toRef(props, 'agents'), heroId, isActive)
// ── Icon resolver ──
function resolveIcon(iconName: string) {
switch (iconName) {
case 'bot': return Bot
case 'code': return Code2
case 'server': return Server
case 'shield': return Shield
case 'search': return Search
case 'terminal': return Terminal
default: return Bot
}
}
// ── Runtime formatter ──
function formatRuntime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
// ── Model formatter ──
function formatModel(model: string): string {
const parts = model.split('/')
const name = parts[parts.length - 1]
return name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
// ── Mobile media query ──
const isMobile = ref(false)
let mq: MediaQueryList | null = null
function onMqChange(e: MediaQueryListEvent) {
isMobile.value = e.matches
}
onMounted(() => {
mq = window.matchMedia('(max-width: 600px)')
isMobile.value = mq.matches
mq.addEventListener('change', onMqChange)
})
onUnmounted(() => {
if (mq) {
mq.removeEventListener('change', onMqChange)
}
})
function visibleTags(tags: string[]) {
if (!isMobile.value || tags.length <= 4) {
return { shown: tags, overflow: 0 }
}
return { shown: tags.slice(0, 4), overflow: tags.length - 4 }
}
// ── Hero computed ──
const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? props.agents[0])
</script>
<template>
<div ref="networkRef" class="ai-team-network">
<!-- SVG Connection Layer -->
<svg
v-if="svgWidth > 0 && svgHeight > 0"
class="network-svg"
:width="svgWidth"
:height="svgHeight"
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter
v-for="agent in childAgents"
:key="`glow-${agent.id}`"
:id="`glow-${agent.id}`"
x="-30%" y="-30%" width="160%" height="160%"
>
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Connection lines for each agent -->
<template v-for="agent in childAgents" :key="agent.id">
<!-- Base line -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePathRef(agent.id)"
:d="connectionPaths[agent.id]!.d"
:stroke="agent.color"
:stroke-width="isActive(agent.id) ? 2.5 : 1.5"
fill="none"
:opacity="isActive(agent.id) ? 0.7 : 0.25"
stroke-linecap="round"
/>
<!-- Glow line for active agent -->
<path
v-if="isActive(agent.id) && connectionPaths[agent.id]"
:d="connectionPaths[agent.id]!.d"
:stroke="agent.color"
stroke-width="4"
fill="none"
stroke-linecap="round"
:filter="`url(#glow-${agent.id})`"
opacity="0.5"
/>
<!-- Pulse line 1 (white dashed segment moving along) -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePulseRef(agent.id)"
:d="connectionPaths[agent.id]!.d"
stroke="white"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:opacity="isActive(agent.id) ? 1 : 0.4"
/>
<!-- Pulse line 2 (offset by half cycle) -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePulseRef2(agent.id)"
:d="connectionPaths[agent.id]!.d"
stroke="white"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:opacity="isActive(agent.id) ? 0.8 : 0.3"
/>
</template>
</svg>
<!-- Cards Layer (above SVG) -->
<div class="cards-layer">
<!-- Hero: Iris centered top -->
<div class="hero-slot" :data-agent-id="hero.id">
<article
class="agent-card hero-card"
:style="{
'--card-color': hero.color,
...(isActive(hero.id) ? {
boxShadow: `0 0 20px ${hero.color}44`,
borderColor: hero.color
} : {})
}"
@click="emit('select', hero.id)"
>
<div class="card-main">
<div class="card-icon-wrap" :style="{ background: `${hero.color}18`, color: hero.color }">
<component :is="resolveIcon(hero.icon)" :size="20" />
</div>
<div class="card-body">
<div class="card-name-row">
<h3 class="card-name">{{ hero.name }}</h3>
<span class="card-role-tag" :style="{ background: `${hero.color}18`, color: hero.color, borderColor: `${hero.color}30` }">{{ hero.role }}</span>
</div>
<p class="card-desc">{{ hero.description }}</p>
<div v-if="hero.currentTask" class="task-row">
<span class="node-task">
<span class="node-task-dot" :style="{ color: hero.color }"></span>
{{ hero.currentTask }}
</span>
<span class="node-runtime">{{ formatRuntime(hero.runtimeSeconds) }}</span>
<span v-if="hero.model" class="node-model">{{ formatModel(hero.model) }}</span>
</div>
<div v-else class="idle-row">
<span class="idle-badge">Idle</span>
</div>
<div class="card-tags">
<template v-for="(tag, idx) in visibleTags(hero.tags).shown" :key="tag">
<span class="card-tag" :style="{ background: `${hero.color}18`, color: hero.color }">{{ tag }}</span>
</template>
<span v-if="visibleTags(hero.tags).overflow > 0" class="card-tag tag-overflow" :style="{ background: `${hero.color}18`, color: hero.color }">+{{ visibleTags(hero.tags).overflow }}</span>
</div>
</div>
</div>
<div class="card-arrow">
<span class="arrow-icon">&rarr;</span>
</div>
</article>
</div>
<!-- Agent Grid: 2 columns x 2 rows -->
<div class="agent-grid">
<div
v-for="agent in childAgents"
:key="agent.id"
:data-agent-id="agent.id"
class="agent-slot"
>
<article
class="agent-card"
:style="{
'--card-color': agent.color,
...(isActive(agent.id) ? {
boxShadow: `0 0 14px ${agent.color}55, 0 0 30px ${agent.color}22`,
borderColor: agent.color
} : {})
}"
@click="emit('select', agent.id)"
>
<div class="card-main">
<div class="card-icon-wrap" :style="{ background: `${agent.color}18`, color: agent.color }">
<component :is="resolveIcon(agent.icon)" :size="18" />
</div>
<div class="card-body">
<div class="card-name-row">
<h3 class="card-name">{{ agent.name }}</h3>
<span class="card-role-tag" :style="{ background: `${agent.color}18`, color: agent.color, borderColor: `${agent.color}30` }">{{ agent.role }}</span>
</div>
<p class="card-desc">{{ agent.description }}</p>
<div v-if="agent.currentTask" class="task-row">
<span class="node-task">
<span class="node-task-dot" :style="{ color: agent.color }"></span>
{{ agent.currentTask }}
</span>
<span class="node-runtime">{{ formatRuntime(agent.runtimeSeconds) }}</span>
<span v-if="agent.model" class="node-model">{{ formatModel(agent.model) }}</span>
</div>
<div v-else class="idle-row">
<span class="idle-badge">Idle</span>
</div>
<div class="card-tags">
<template v-for="(tag, idx) in visibleTags(agent.tags).shown" :key="tag">
<span class="card-tag" :style="{ background: `${agent.color}18`, color: agent.color }">{{ tag }}</span>
</template>
<span v-if="visibleTags(agent.tags).overflow > 0" class="card-tag tag-overflow" :style="{ background: `${agent.color}18`, color: agent.color }">+{{ visibleTags(agent.tags).overflow }}</span>
</div>
</div>
</div>
<div class="card-arrow">
<span class="arrow-icon">&rarr;</span>
</div>
</article>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.ai-team-network {
position: relative;
width: 100%;
background: transparent;
}
.network-svg {
position: absolute;
top: 0;
left: 0;
z-index: 0;
pointer-events: none;
overflow: visible;
}
.cards-layer {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 64px;
}
.hero-slot {
width: 100%;
max-width: 520px;
transition: border-color 0.3s, box-shadow 0.3s;
}
.agent-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
width: 100%;
max-width: 820px;
}
.agent-slot {
width: 100%;
transition: border-color 0.3s, box-shadow 0.3s;
}
/* ── Agent Card ── */
.agent-card {
background: rgba(18, 22, 30, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 18px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
overflow: hidden;
position: relative;
}
.agent-card:hover {
background: rgba(18, 22, 30, 0.65);
border-color: var(--card-color, #8b7cf6);
box-shadow: 0 0 16px color-mix(in srgb, var(--card-color, #8b7cf6) 10%, transparent);
}
.hero-card {
background: rgba(18, 22, 30, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 0 20px rgba(139, 124, 246, 0.06);
}
.hero-card:hover {
background: rgba(18, 22, 30, 0.65);
border-color: #8b7cf6;
box-shadow: 0 0 24px rgba(139, 124, 246, 0.12);
}
.card-main {
display: flex;
gap: 14px;
align-items: flex-start;
}
.card-icon-wrap {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 10px;
flex-shrink: 0;
}
.card-body {
flex: 1;
min-width: 0;
}
.card-name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
flex-wrap: wrap;
}
.card-name {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #e8eaf0;
}
.card-role-tag {
display: inline-block;
font-size: 8.5px;
font-weight: 600;
padding: 2px 8px;
border-radius: 5px;
border: 1px solid transparent;
white-space: nowrap;
}
.card-desc {
font-size: 10.5px;
color: #7e8799;
line-height: 1.5;
margin: 0 0 8px;
}
/* ── Task + Runtime Row ── */
.task-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.node-task {
display: inline-flex;
align-items: center;
font-size: 10px;
color: #9ea5b3;
line-height: 1.4;
flex: 1;
min-width: 0;
}
.node-task-dot {
display: inline-block;
margin-right: 4px;
font-size: 8px;
vertical-align: middle;
}
.node-runtime {
font-size: 9px;
color: #6b7385;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.node-model {
font-size: 8.5px;
color: #6b7385;
font-weight: 500;
flex-shrink: 0;
margin-left: 6px;
}
/* ── Idle Row ── */
.idle-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.idle-badge {
font-size: 9px;
color: #6b7385;
padding: 2px 8px;
border-radius: 4px;
background: rgba(107, 115, 133, 0.15);
}
/* ── Tags ── */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.card-tag {
display: inline-block;
font-size: 9px;
font-weight: 600;
padding: 2px 8px;
border-radius: 5px;
letter-spacing: 0.02em;
}
.tag-overflow {
opacity: 0.7;
}
/* ── Hover Arrow ── */
.card-arrow {
position: absolute;
right: 12px;
bottom: 12px;
color: #6b7385;
opacity: 0;
transform: translateX(-6px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.agent-card:hover .card-arrow {
opacity: 1;
transform: translateX(0);
}
.arrow-icon {
font-size: 14px;
line-height: 1;
display: block;
}
/* ── Tablet ── */
@media (max-width: 900px) {
.agent-grid {
max-width: 100%;
gap: 12px;
}
.hero-slot {
max-width: 100%;
}
.card-name {
font-size: 13px;
}
.card-desc {
font-size: 9.5px;
}
}
/* ── Mobile ── */
@media (max-width: 600px) {
.agent-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.agent-card {
padding: 12px;
}
.card-icon-wrap {
width: 34px;
height: 34px;
}
.card-name {
font-size: 12px;
}
.card-role-tag {
font-size: 7.5px;
}
.card-desc {
font-size: 9px;
}
.card-tag {
font-size: 8px;
padding: 1px 6px;
}
.cards-layer {
gap: 24px;
}
}
</style>