feat: AI Team Network ins Dashboard integriert, Vollbreite, dynamisches Grid
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s

- TeamNetwork ins Dashboard verschoben (center column)
- 3-Spalten-Layout auf volle Desktop-Breite (kein max-width)
- Agent-Grid dynamisch: 2 Spalten, erweitert nach unten (4→6→8 Agenten)
- SVG-Bézier-Linien mit ResizeObserver passen sich an
- 'Team' aus Navigation, Router und standaloneViews entfernt
- /team Route gelöscht
This commit is contained in:
2026-06-09 21:49:10 +02:00
parent 774a5a44f3
commit 5244e9fd3d
4 changed files with 289 additions and 111 deletions
+2 -2
View File
@@ -20,7 +20,7 @@ const activeView = computed(() => {
}) })
const routePaths: Record<string, string> = { const routePaths: Record<string, string> = {
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Team: '/team', Security: '/security', Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar', Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Settings: '/settings', Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Settings: '/settings',
} }
@@ -31,7 +31,7 @@ const navigate = (label: string) => {
} }
const mobileNavOpen = ref(false) const mobileNavOpen = ref(false)
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Team', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents'].includes(activeView.value)) const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents'].includes(activeView.value))
onMounted(() => { onMounted(() => {
if (auth.isAuthenticated) store.refresh() if (auth.isAuthenticated) store.refresh()
+286 -105
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { Bot, Sparkles } from '@lucide/vue' import { Bot, Sparkles } from '@lucide/vue'
import AgentNode from './AgentNode.vue' import AgentNode from './AgentNode.vue'
import AgentModal from './AgentModal.vue' import AgentModal from './AgentModal.vue'
@@ -23,41 +23,217 @@ function closeModal(): void {
selectedAgent.value = null selectedAgent.value = null
} }
const agentColorMap: Record<string, string> = { // ── Layout measurement ──
developer: '#3b82f6', const networkRef = ref<HTMLDivElement | null>(null)
devops: '#eab308',
researcher: '#22c55e', interface CardBox {
reviewer: '#a855f7', left: number
right: number
top: number
bottom: number
cx: number
cy: number
width: number
height: number
}
const cardPositions = ref<Record<string, CardBox>>({})
const svgWidth = ref(0)
const svgHeight = ref(0)
function updatePositions() {
if (!networkRef.value) return
const rect = networkRef.value.getBoundingClientRect()
svgWidth.value = rect.width
svgHeight.value = rect.height
const positions: Record<string, CardBox> = {}
const irisEl = networkRef.value.querySelector('[data-agent-id="iris"]')
if (irisEl) {
const r = irisEl.getBoundingClientRect()
positions['iris'] = {
left: r.left - rect.left,
right: r.left + r.width - rect.left,
top: r.top - rect.top,
bottom: r.top + r.height - rect.top,
cx: r.left + r.width / 2 - rect.left,
cy: r.top + r.height / 2 - rect.top,
width: r.width,
height: r.height,
}
}
const cards = networkRef.value.querySelectorAll('[data-agent-id]')
cards.forEach(el => {
const id = el.getAttribute('data-agent-id')
if (!id || id === 'iris') return
const r = el.getBoundingClientRect()
positions[id] = {
left: r.left - rect.left,
right: r.left + r.width - rect.left,
top: r.top - rect.top,
bottom: r.top + r.height - rect.top,
cx: r.left + r.width / 2 - rect.left,
cy: r.top + r.height / 2 - rect.top,
width: r.width,
height: r.height,
}
})
cardPositions.value = positions
} }
const agentLineActive: Record<string, boolean> = { // ── SVG connection paths ──
developer: true, interface ConnectionPath {
devops: false, d: string
researcher: true, length: number
reviewer: false,
} }
const NETWORK_W = 440 const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
const IRIS_CX = NETWORK_W / 2 const result: Record<string, ConnectionPath | null> = {}
const IRIS_CY = 80 const pos = cardPositions.value
const AGENT_START_Y = 170 const iris = pos['iris']
if (!iris) return result
const agentPositions = computed(() => [ for (const agent of props.agents) {
{ id: 'developer', x: 60, y: AGENT_START_Y }, const agentPos = pos[agent.id]
{ id: 'researcher', x: NETWORK_W - 60, y: AGENT_START_Y }, if (!agentPos) {
{ id: 'devops', x: 60, y: AGENT_START_Y + 110 }, result[agent.id] = null
{ id: 'reviewer', x: NETWORK_W - 60, y: AGENT_START_Y + 110 }, continue
]) }
const activeLines = computed(() => const startX = iris.cx
agentPositions.value.filter(p => agentLineActive[p.id]) const startY = iris.bottom - 1
) const endX = agentPos.cx
const endY = agentPos.top + 4
// Bézier control points
const dy = endY - startY
const cy1 = startY + dy * 0.4
const cy2 = startY + dy * 0.7
const d = `M ${startX} ${startY} C ${startX} ${cy1}, ${endX} ${cy2}, ${endX} ${endY}`
result[agent.id] = { d, length: 0 }
}
return result
})
// ── Pulse animation (JS-driven via requestAnimationFrame) ──
let animFrameId: number | null = null
let lastAnimTime = 0
const pathElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
const pulseOffsets = ref<Record<string, number>>({})
function storePathRef(id: string) {
return (el: SVGPathElement | null) => {
pathElements.value[id] = el
}
}
function storePulseRef(id: string) {
return (el: SVGPathElement | null) => {
pulseElements.value[id] = el
}
}
function refreshPathLengths() {
for (const agent of props.agents) {
const pathEl = pathElements.value[agent.id]
const pulseEl = pulseElements.value[agent.id]
const p = connectionPaths.value[agent.id]
if (pathEl && p) {
p.length = pathEl.getTotalLength()
}
if (pulseEl && p && p.length > 0) {
if (pulseOffsets.value[agent.id] === undefined) {
pulseOffsets.value[agent.id] = 0
}
pulseEl.setAttribute('stroke-dasharray', `12 ${p.length}`)
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[agent.id]))
}
}
}
function startPulseAnimation() {
refreshPathLengths()
const speeds: Record<string, number> = {}
for (const agent of props.agents) {
const p = connectionPaths.value[agent.id]
if (p && p.length > 0) {
speeds[agent.id] = p.length / 3000 // full traversal in ~3s
if (pulseOffsets.value[agent.id] === undefined) {
pulseOffsets.value[agent.id] = 0
}
}
}
lastAnimTime = performance.now()
function tick(now: number) {
const dt = now - lastAnimTime
lastAnimTime = now
for (const agent of props.agents) {
const pulseEl = pulseElements.value[agent.id]
const p = connectionPaths.value[agent.id]
if (!pulseEl || !p || p.length <= 0) continue
const currentOffset = pulseOffsets.value[agent.id] ?? 0
const speed = speeds[agent.id] ?? p.length / 3000
const newOffset = currentOffset + speed * dt
const cycleLen = p.length + 12
pulseOffsets.value[agent.id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[agent.id]))
}
animFrameId = requestAnimationFrame(tick)
}
animFrameId = requestAnimationFrame(tick)
}
function stopPulseAnimation() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId)
animFrameId = null
}
}
// ── Lifecycle ──
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
await nextTick()
updatePositions()
await nextTick()
updatePositions()
refreshPathLengths()
startPulseAnimation()
resizeObserver = new ResizeObserver(() => {
updatePositions()
requestAnimationFrame(() => {
refreshPathLengths()
})
})
if (networkRef.value) {
resizeObserver.observe(networkRef.value)
}
})
onUnmounted(() => {
stopPulseAnimation()
resizeObserver?.disconnect()
})
</script> </script>
<template> <template>
<div class="team-network"> <div ref="networkRef" class="team-network">
<!-- Iris Node --> <!-- Iris Node -->
<div class="iris-node"> <div class="iris-node" data-agent-id="iris">
<div class="iris-avatar-ring"> <div class="iris-avatar-ring">
<svg class="ring-svg" viewBox="0 0 60 60" width="60" height="60"> <svg class="ring-svg" viewBox="0 0 60 60" width="60" height="60">
<circle cx="30" cy="30" r="27" fill="none" stroke="rgba(167,139,250,0.12)" stroke-width="2" /> <circle cx="30" cy="30" r="27" fill="none" stroke="rgba(167,139,250,0.12)" stroke-width="2" />
@@ -88,58 +264,85 @@ const activeLines = computed(() =>
</div> </div>
</div> </div>
<!-- SVG Connections --> <!-- SVG Connection Layer -->
<svg <svg
v-if="svgWidth > 0 && svgHeight > 0"
class="network-svg" class="network-svg"
:viewBox="`0 0 ${NETWORK_W} 400`" :width="svgWidth"
preserveAspectRatio="xMidYMid meet" :height="svgHeight"
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
xmlns="http://www.w3.org/2000/svg"
> >
<defs> <defs>
<filter id="lineglow"> <filter
<feGaussianBlur stdDeviation="2" result="blur" /> v-for="agent in agents"
:key="`glow-${agent.id}`"
:id="`glow-${agent.id}`"
x="-30%" y="-30%" width="160%" height="160%"
>
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge> <feMerge>
<feMergeNode in="blur" />
<feMergeNode in="blur" /> <feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" /> <feMergeNode in="SourceGraphic" />
</feMerge> </feMerge>
</filter> </filter>
</defs> </defs>
<line <template v-for="agent in agents" :key="agent.id">
v-for="pos in agentPositions" <!-- Base connection line -->
:key="'conn-' + pos.id" <path
:x1="IRIS_CX" :y1="IRIS_CY + 32" v-if="connectionPaths[agent.id]"
:x2="pos.x + 22" :y2="pos.y" :ref="storePathRef(agent.id)"
:stroke="agentColorMap[pos.id]" :d="connectionPaths[agent.id]!.d"
:stroke-width="agentLineActive[pos.id] ? 1.5 : 1" :stroke="agent.color"
:opacity="agentLineActive[pos.id] ? 0.4 : 0.1" :stroke-width="agent.active ? 2.5 : 1.5"
class="conn-line" fill="none"
:class="{ 'line-pulse': agentLineActive[pos.id] }" :opacity="agent.active ? 0.7 : 0.25"
filter="url(#lineglow)" stroke-linecap="round"
/> />
<g v-for="pos in activeLines" :key="'fx-' + pos.id"> <!-- Glow for active agents -->
<circle <path
:cx="pos.x + 22" :cy="pos.y" v-if="agent.active && connectionPaths[agent.id]"
r="2.5" fill="#ffffff" :d="connectionPaths[agent.id]!.d"
class="pulse-end" :style="{ '--pulse-color': agentColorMap[pos.id] }" :stroke="agent.color"
stroke-width="4"
fill="none"
stroke-linecap="round"
:filter="`url(#glow-${agent.id})`"
opacity="0.5"
/> />
<circle
:cx="IRIS_CX" :cy="IRIS_CY + 32" <!-- Moving pulse dot -->
r="2.5" fill="#ffffff" <path
class="pulse-origin" 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="agent.active ? 1 : 0.4"
/> />
</g> </template>
</svg> </svg>
<!-- Agent Cards --> <!-- Agent Grid (dynamic: 2 columns, extends downward) -->
<div class="agents-grid"> <div class="agents-grid">
<AgentNode <div
v-for="agent in agents" v-for="agent in agents"
:key="agent.id" :key="agent.id"
:agent="agent" :data-agent-id="agent.id"
:runtime="getAgentRuntime(agent.id)" class="agent-slot"
@select="onAgentSelect" >
/> <AgentNode
:agent="agent"
:runtime="getAgentRuntime(agent.id)"
@select="onAgentSelect"
/>
</div>
</div> </div>
<!-- Focus Banner --> <!-- Focus Banner -->
@@ -160,24 +363,21 @@ const activeLines = computed(() =>
<style scoped> <style scoped>
.team-network { .team-network {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 24px 20px 20px;
background: rgba(22, 27, 34, 0.75);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
transition: border-color 0.2s ease;
position: relative; position: relative;
width: 100%;
padding: 24px 20px 20px;
background: rgba(22, 27, 34, 0.5);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 16px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
min-height: 480px;
transition: border-color 0.2s ease;
overflow: hidden; overflow: hidden;
min-height: 520px;
} }
.team-network:hover { .team-network:hover {
border-color: rgba(139, 124, 246, 0.18); border-color: rgba(139, 124, 246, 0.14);
} }
/* Iris Node */ /* Iris Node */
@@ -187,6 +387,7 @@ const activeLines = computed(() =>
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 20px 28px; padding: 20px 28px;
margin-bottom: 8px;
background: rgba(167, 139, 250, 0.06); background: rgba(167, 139, 250, 0.06);
border: 1px solid rgba(167, 139, 250, 0.15); border: 1px solid rgba(167, 139, 250, 0.15);
border-radius: 14px; border-radius: 14px;
@@ -194,6 +395,8 @@ const activeLines = computed(() =>
z-index: 2; z-index: 2;
width: 100%; width: 100%;
max-width: 320px; max-width: 320px;
margin-left: auto;
margin-right: auto;
} }
.iris-avatar-ring { .iris-avatar-ring {
position: relative; position: relative;
@@ -267,50 +470,28 @@ const activeLines = computed(() =>
color: #a78bfa; color: #a78bfa;
} }
/* SVG Lines */ /* SVG Connection Lines */
.network-svg { .network-svg {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1; z-index: 1;
} pointer-events: none;
.conn-line { overflow: visible;
transition: opacity 0.4s ease, stroke-width 0.4s ease;
}
.line-pulse {
animation: line-glow 2s ease-in-out infinite;
}
@keyframes line-glow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
.pulse-origin {
animation: pulse-origin 2s ease-in-out infinite;
}
.pulse-end {
animation: pulse-end 2s ease-in-out infinite;
}
@keyframes pulse-origin {
0%, 100% { opacity: 0; r: 1.5; }
50% { opacity: 0.8; r: 3.5; }
}
@keyframes pulse-end {
0%, 100% { opacity: 0.2; r: 1.5; }
50% { opacity: 0.9; r: 3; }
} }
/* Agent Cards */ /* Agent Grid — dynamic, 2 columns, extends downward as agents grow */
.agents-grid { .agents-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 10px; gap: 12px;
width: 100%; width: 100%;
position: relative; position: relative;
z-index: 2; z-index: 2;
margin-top: 4px; margin-top: 8px;
}
.agent-slot {
width: 100%;
} }
/* Focus Banner */ /* Focus Banner */
@@ -320,10 +501,10 @@ const activeLines = computed(() =>
gap: 8px; gap: 8px;
width: 100%; width: 100%;
padding: 10px 16px; padding: 10px 16px;
margin-top: 12px;
background: rgba(234, 179, 8, 0.05); background: rgba(234, 179, 8, 0.05);
border: 1px solid rgba(234, 179, 8, 0.1); border: 1px solid rgba(234, 179, 8, 0.1);
border-radius: 10px; border-radius: 10px;
margin-top: 4px;
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
@@ -3,7 +3,7 @@ import { computed } from 'vue'
import { import {
Activity, Bot, Boxes, Command, FileText, Activity, Bot, Boxes, Command, FileText,
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings, LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
Shield, SlidersHorizontal, Sparkles, Users, BookOpen, Shield, SlidersHorizontal, Sparkles, BookOpen,
AlertTriangle, Calendar, AlertTriangle, Calendar,
} from '@lucide/vue' } from '@lucide/vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -29,7 +29,6 @@ const navigation = [
{ label: 'Dashboard', icon: LayoutDashboard }, { label: 'Dashboard', icon: LayoutDashboard },
{ label: 'Memory', icon: FileText }, { label: 'Memory', icon: FileText },
{ label: 'Docs', icon: BookOpen }, { label: 'Docs', icon: BookOpen },
{ label: 'Team', icon: Users },
{ label: 'Security', icon: Shield }, { label: 'Security', icon: Shield },
{ label: 'Projects', icon: Boxes }, { label: 'Projects', icon: Boxes },
{ label: 'Task Board', icon: ListTodo }, { label: 'Task Board', icon: ListTodo },
-2
View File
@@ -4,7 +4,6 @@ import ProjectDetailView from './views/ProjectDetailView.vue'
import SettingsView from './views/SettingsView.vue' import SettingsView from './views/SettingsView.vue'
import MemoryView from './views/MemoryView.vue' import MemoryView from './views/MemoryView.vue'
import DocsView from './views/DocsView.vue' import DocsView from './views/DocsView.vue'
import TeamView from './views/TeamView.vue'
import AgentDetailView from './views/AgentDetailView.vue' import AgentDetailView from './views/AgentDetailView.vue'
import AgentsIndexView from './views/AgentsIndexView.vue' import AgentsIndexView from './views/AgentsIndexView.vue'
import SecurityView from './views/SecurityView.vue' import SecurityView from './views/SecurityView.vue'
@@ -18,7 +17,6 @@ const routes = [
{ path: '/dashboard', name: 'Dashboard', component: DashboardView }, { path: '/dashboard', name: 'Dashboard', component: DashboardView },
{ path: '/memory', name: 'Memory', component: MemoryView }, { path: '/memory', name: 'Memory', component: MemoryView },
{ path: '/docs', name: 'Docs', component: DocsView }, { path: '/docs', name: 'Docs', component: DocsView },
{ path: '/team', name: 'Team', component: TeamView },
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView }, { path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView },
{ path: '/security', name: 'Security', component: SecurityView }, { path: '/security', name: 'Security', component: SecurityView },
{ path: '/incidents', name: 'Incidents', component: IncidentsView }, { path: '/incidents', name: 'Incidents', component: IncidentsView },