Files
nexus/frontend/src/components/dashboard/v2/AgentDetailModal.vue
T
reviewer 4ad0f9e493
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
refactor: SOLID architecture — backend service layer + frontend V2 components
## Backend — Service Layer & Repository Refactoring

### Neue Services (21 neue Dateien)

**Interfaces & Implementierungen:**
- `IOpenClawGatewayClient` — Interface für OpenClawGatewayClient (DIP-Fix: DashboardController hing an konkreter Klasse)
- `IAgentConfigService` / `AgentConfigService` — Agent-Config-File-I/O aus AgentsController extrahiert
- `IProjectService` / `ProjectService` — Projekt-CRUD + Activity-Logging (SRP)
- `ITaskService` / `TaskService` — Task-State-Machine, Approve/Reject, Dashboard-Operationen (eliminiert Duplikation zwischen TasksController und DashboardController)
- `IDashboardService` / `DashboardService` — Queue-Aggregation, Priority-Normalisierung, Gateway-Delegation
- `IOperationsService` / `OperationsService` — Metriken-Berechnung aus OperationsController
- `ITeamService` / `TeamService` — IDENTITY.md-Lesen aus TeamController
- `IMemoryService` / `MemoryService` — File-I/O aus MemoryController
- `IIncidentService` / `IncidentService` — File-Parsing (Regex-Source-Generatoren) aus IncidentsController
- `IDocService` / `DocService` — Directory-Scan aus DocsController
- `ICalendarService` / `CalendarService` — Gateway-HTTP-Calls + Fallback-Daten aus CalendarController

### Repository-Fixes

**IUserRepository / UserRepository:**
- `SaveChangesAsync` entfernt (leaky abstraction — Caller sollten nie SaveChanges steuern)
- `RevokeTokenAsync(tokenHash)` — atomares Token-Revoke inkl. SaveChanges
- `RevokeFamilyAsync(familyId)` — Batch-Revoke einer Token-Familie inkl. SaveChanges
- `RemoveExpiredTokensAsync` speichert jetzt selbst (war vorher dependent auf nachfolgenden Save)

### AuthService-Fixes
- `GetUserAsync`: unnötiges `Task.Run` entfernt → direkt `_users.GetByIdAsync().AsTask()`
- `RevokeAsync`: delegiert jetzt an `IUserRepository.RevokeTokenAsync`
- `RefreshAsync`: Token-Reuse-Detection delegiert an `IUserRepository.RevokeFamilyAsync`

### Bug-Fix
- `OpenClawGatewayClient.ReadAgentGoalAsync`: pre-existing `CS1656` behoben (`reader` war `using`-Variable und wurde neu zugewiesen — in `reader2` umbenannt)

### Controller (16 Stück — alle slim)
Alle Controller reduziert auf: Input validieren → Service aufrufen → HTTP-Result zurückgeben.
Kein Business-Logic, kein File-I/O, keine direkte Repository-Nutzung (außer AgentsController für Activity-Log).

**Program.cs — neue Registrierungen:**
- `AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>` (war vorher konkrete Klasse)
- Scoped: IDashboardService, IProjectService, ITaskService, IOperationsService, ITeamService, ICalendarService
- Singleton: IAgentConfigService, IMemoryService, IIncidentService, IDocService

---

## Frontend — Dashboard V2 Components

**AgentDetailModal.vue, IrisChat.vue, TaskStrip.vue:**
- V2 Design-System: Dark Space Theme, Glass-Panels, Gradient-Akzente
- Stores (agents, chat, tasks) nutzen Service + Mapper-Pattern
- NexusLayout, FlowBoard, Topbar — Layoutfixes für fullHeight-Route-Meta

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 08:34:58 +02:00

570 lines
14 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">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import type { AgentDetailData } from './types'
import { icons } from '../../../composables/icons'
const props = defineProps<{
agent: AgentDetailData
agentOrder: string[]
}>()
const emit = defineEmits<{
close: []
select: [id: string]
changeModel: [agentId: string, modelId: string]
}>()
/* ── Progress animation ────────────────────────── */
const displayProgress = ref(0)
function animateProgress() {
displayProgress.value = 0
setTimeout(() => { displayProgress.value = props.agent.progress }, 60)
}
/* ── Typewriter ────────────────────────────────── */
const thinkDisplay = ref('')
let thinkTimer: ReturnType<typeof setInterval> | null = null
function startTypewriter() {
if (thinkTimer) clearInterval(thinkTimer)
thinkDisplay.value = ''
if (!props.agent.think) return
const text = props.agent.think
let i = 0
thinkTimer = setInterval(() => {
thinkDisplay.value = text.slice(0, i)
i = i >= text.length ? 0 : i + 1
}, 38)
}
/* ── Selected model ────────────────────────────── */
const selectedModel = ref(props.agent.model)
watch(() => props.agent.id, () => {
selectedModel.value = props.agent.model
animateProgress()
startTypewriter()
})
function selectModel(alias: string) {
selectedModel.value = alias
emit('changeModel', props.agent.id, alias)
}
/* ── Keyboard / Backdrop ───────────────────────── */
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { emit('close'); return }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
const idx = props.agentOrder.indexOf(props.agent.id)
if (idx === -1) return
const next = e.key === 'ArrowRight'
? (idx + 1) % props.agentOrder.length
: (idx - 1 + props.agentOrder.length) % props.agentOrder.length
emit('select', props.agentOrder[next])
}
}
function onBackdrop(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('modal-ov')) emit('close')
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
animateProgress()
startTypewriter()
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
if (thinkTimer) clearInterval(thinkTimer)
})
const statusColors: Record<string, string> = {
work: 'var(--st-work)',
think: 'var(--st-think)',
idle: 'var(--st-idle)',
block: 'var(--st-block)',
}
</script>
<template>
<div class="modal-ov" @click="onBackdrop">
<div class="modal-card">
<!-- Close -->
<button class="m-close" @click="emit('close')">×</button>
<!-- Header -->
<div class="m-head">
<div :class="['m-av', { iris: agent.id === 'iris' }]">
{{ agent.id === 'iris' ? 'IR' : agent.name.slice(0, 2).toUpperCase() }}
</div>
<div style="flex:1; min-width:0">
<div class="m-name">{{ agent.name }}</div>
<div class="m-sub">
<span :class="['badge', agent.roleBadge]">{{ agent.role }}</span>
<span class="m-pill">{{ selectedModel }}</span>
<span class="m-status" :style="{ color: statusColors[agent.status] }">
<span class="dot" :class="agent.status"></span>
{{ agent.statusLabel }}
</span>
</div>
</div>
</div>
<!-- Aktuelle Aufgabe -->
<div v-if="agent.task" class="m-sec">
<h4>Aktuelle Aufgabe</h4>
<div class="m-task">{{ agent.task }}</div>
<div class="m-goal">
<span v-html="icons.target || ''"></span>
Ziel: {{ agent.goal }}
</div>
<div class="m-bar" :class="{ work: agent.status === 'work' }">
<i :style="{ width: displayProgress + '%' }"></i>
</div>
<div class="m-pct-row">
<div class="m-pct grad-tx">{{ displayProgress }}%</div>
<div class="m-next"> {{ agent.next }}</div>
</div>
</div>
<!-- Metriken -->
<div class="m-sec">
<h4>Metriken</h4>
<div class="m-metrics">
<div class="m-metric">
<div class="mk">Elapsed</div>
<div class="mv">{{ agent.elapsed }}</div>
</div>
<div class="m-metric">
<div class="mk">Token</div>
<div class="mv">{{ agent.tokens }}</div>
</div>
<div class="m-metric">
<div class="mk">Kosten</div>
<div class="mv grad-tx">${{ agent.cost }}</div>
</div>
<div class="m-metric">
<div class="mk">Fortschritt</div>
<div class="mv">{{ agent.progress }}%</div>
</div>
</div>
</div>
<!-- Live Thinking -->
<div v-if="agent.think" class="m-sec">
<h4>Live Thinking</h4>
<div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
</div>
<!-- Modell wählen -->
<div class="m-sec">
<h4>Modell wählen</h4>
<div class="m-models">
<button
v-for="m in agent.availableModels"
:key="m.id"
:class="['m-model-btn', { active: m.alias === selectedModel }]"
@click="selectModel(m.alias)"
>{{ m.alias }}</button>
</div>
</div>
<!-- MD Footer -->
<div v-if="agent.md" class="m-md">
<span class="dot work"></span>
Synced: <span class="m-md-path">{{ agent.md }}</span>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Overlay ─────────────────────────────────── */
.modal-ov {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5,4,16,.78);
backdrop-filter: blur(16px);
animation: ov-in .2s ease-out;
}
@keyframes ov-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ── Card ────────────────────────────────────── */
.modal-card {
width: 640px;
max-height: 88vh;
overflow-y: auto;
border-radius: 20px;
position: relative;
background: linear-gradient(160deg, rgba(22,18,50,.97), rgba(10,8,28,.97));
border: 1px solid rgba(150,140,255,.28);
box-shadow:
0 0 0 1px rgba(124,108,255,.15),
0 32px 80px -16px rgba(0,0,0,.8),
0 0 60px -10px rgba(124,108,255,.35);
animation: card-in .24s cubic-bezier(.2,.8,.3,1);
}
@keyframes card-in {
from { opacity: 0; transform: scale(.94) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-card::-webkit-scrollbar { width: 7px; }
.modal-card::-webkit-scrollbar-thumb {
background: rgba(124,108,255,.22);
border-radius: 7px;
border: 2px solid transparent;
background-clip: padding-box;
}
.modal-card::-webkit-scrollbar-track { background: transparent; }
/* ── Close ───────────────────────────────────── */
.m-close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border-radius: 9px;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
transition: .15s;
z-index: 2;
}
.m-close:hover {
background: rgba(124,108,255,.18);
color: var(--tx);
}
/* ── Head ────────────────────────────────────── */
.m-head {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 26px 26px 20px;
}
.m-av {
width: 56px;
height: 56px;
border-radius: 15px;
flex: 0 0 auto;
display: grid;
place-items: center;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 16px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
color: var(--tx);
}
.m-av.iris {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.m-name {
font-family: 'Space Grotesk', sans-serif;
font-size: 21px;
font-weight: 700;
line-height: 1.1;
color: var(--tx);
}
.m-sub {
display: flex;
align-items: center;
gap: 9px;
margin-top: 7px;
flex-wrap: wrap;
}
/* ── Badges ──────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
border: 1px solid transparent;
}
.badge-blue { background:rgba(79,124,255,.14); color:#9db6ff; border-color:rgba(79,124,255,.3); }
.badge-purple { background:rgba(181,87,246,.14); color:#d7a8ff; border-color:rgba(181,87,246,.3); }
.badge-amber { background:rgba(251,191,36,.13); color:#fcd34d; border-color:rgba(251,191,36,.3); }
.badge-green { background:rgba(61,220,151,.13); color:#7ef0bd; border-color:rgba(61,220,151,.3); }
.badge-cyan { background:rgba(52,214,245,.13); color:#8ee9fb; border-color:rgba(52,214,245,.3); }
.badge-rose { background:rgba(251,113,133,.13); color:#fda4b0; border-color:rgba(251,113,133,.3); }
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
.m-pill {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 11px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
}
.m-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.work { background: var(--st-work); box-shadow: 0 0 0 0 rgba(61,220,151,.5); animation: pulse-work 1.8s infinite; }
.dot.think { background: var(--st-think); box-shadow: 0 0 0 0 rgba(52,214,245,.5); animation: pulse-think 1.6s infinite; }
.dot.idle { background: var(--st-idle); }
.dot.block { background: var(--st-block); }
/* ── Section ─────────────────────────────────── */
.m-sec {
padding: 16px 26px;
border-top: 1px solid var(--line);
}
.m-sec h4 {
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--tx-3);
font-weight: 700;
margin: 0 0 13px;
}
/* ── Task ────────────────────────────────────── */
.m-task {
font-size: 15px;
font-weight: 700;
margin-bottom: 8px;
color: var(--tx);
}
.m-goal {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: var(--tx-2);
margin-bottom: 14px;
}
.m-goal :deep(svg) {
width: 14px;
height: 14px;
color: var(--a-mid);
flex: 0 0 auto;
}
/* ── Progress Bar ────────────────────────────── */
.m-bar {
height: 10px;
border-radius: 10px;
background: rgba(124,108,255,.12);
overflow: hidden;
position: relative;
}
.m-bar i {
display: block;
height: 100%;
border-radius: 10px;
background: var(--grad);
box-shadow: 0 0 14px -2px rgba(124,108,255,.8);
transition: width .6s ease;
position: relative;
}
.m-bar.work i {
background: linear-gradient(90deg, #2bb87f, #3ddc97);
box-shadow: var(--glow-work);
}
.m-bar i::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.4), transparent);
animation: shimmer 2s linear infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(180%); }
}
.m-pct-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.m-pct {
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
font-weight: 700;
}
.grad-tx {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.m-next {
font-size: 12px;
color: var(--tx-3);
font-family: 'JetBrains Mono', monospace;
}
/* ── Metrics ─────────────────────────────────── */
.m-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.m-metric {
padding: 11px 13px;
border-radius: 12px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
}
.mk {
font-size: 10px;
color: var(--tx-3);
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.mv {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
margin-top: 5px;
color: var(--tx);
}
/* ── Live Thinking ───────────────────────────── */
.m-think {
background: rgba(5,20,36,.7);
border: 1px solid rgba(52,214,245,.2);
border-radius: 13px;
padding: 14px 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.7;
color: #9fe8fb;
min-height: 72px;
position: relative;
overflow: hidden;
}
.m-think::before {
content: '▶ thinking';
position: absolute;
top: 10px;
right: 14px;
font-size: 9px;
color: var(--st-think);
letter-spacing: .12em;
opacity: .7;
}
.caret::after {
content: '▍';
animation: blink 1s steps(1) infinite;
color: var(--st-think);
}
@keyframes blink { 50% { opacity: 0; } }
/* ── Models ──────────────────────────────────── */
.m-models {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.m-model-btn {
height: 34px;
padding: 0 14px;
border-radius: 9px;
font-family: 'Manrope', sans-serif;
font-size: 12.5px;
font-weight: 600;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.06);
color: var(--tx-2);
cursor: pointer;
transition: .15s;
}
.m-model-btn:hover {
border-color: var(--line-3);
color: var(--tx);
}
.m-model-btn.active {
background: var(--grad);
border: none;
color: #fff;
box-shadow: var(--glow-purple);
}
/* ── MD Footer ───────────────────────────────── */
.m-md {
display: flex;
align-items: center;
gap: 9px;
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
color: var(--tx-2);
padding: 12px 26px;
border-top: 1px solid var(--line);
}
.m-md .dot { width: 7px; height: 7px; }
.m-md-path { color: var(--tx-3); }
</style>