feat: wire dashboard v2 to backend data
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled

This commit is contained in:
AzuTear
2026-06-14 15:43:30 +02:00
parent 38dc2efc6c
commit 64459ccdb3
10 changed files with 227 additions and 9 deletions
@@ -160,6 +160,16 @@ const statusColors: Record<string, string> = {
<div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div> <div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
</div> </div>
<div v-if="agent.activity.length" class="m-sec">
<h4>Agent Activity</h4>
<div class="m-activity">
<div v-for="(entry, index) in agent.activity" :key="index" class="m-activity-row">
<span class="m-activity-time">{{ entry.time }}</span>
<span class="m-activity-text">{{ entry.text }}</span>
</div>
</div>
</div>
<!-- Modell wählen --> <!-- Modell wählen -->
<div class="m-sec"> <div class="m-sec">
<h4>Modell wählen</h4> <h4>Modell wählen</h4>
@@ -519,6 +529,35 @@ const statusColors: Record<string, string> = {
@keyframes blink { 50% { opacity: 0; } } @keyframes blink { 50% { opacity: 0; } }
.m-activity {
display: flex;
flex-direction: column;
gap: 10px;
}
.m-activity-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 10px;
align-items: start;
padding: 10px 12px;
border-radius: 12px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
}
.m-activity-time {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
}
.m-activity-text {
font-size: 12px;
line-height: 1.45;
color: var(--tx-2);
}
/* ── Models ──────────────────────────────────── */ /* ── Models ──────────────────────────────────── */
.m-models { .m-models {
display: flex; display: flex;
@@ -19,6 +19,7 @@ defineProps<{
blockerCount: number blockerCount: number
todayCost: string todayCost: string
todayTokens: string todayTokens: string
blockerLabel?: string
}>() }>()
defineEmits<{ defineEmits<{
@@ -62,7 +63,7 @@ defineEmits<{
@click="$emit('blockerClick')" @click="$emit('blockerClick')"
> >
<span class="dot block"></span> <span class="dot block"></span>
{{ blockerCount }} Blocker {{ blockerLabel || `${blockerCount} Blocker` }}
</button> </button>
</div> </div>
</template> </template>
@@ -16,6 +16,8 @@ export interface TaskItem {
priority: 'high' | 'medium' | 'low' priority: 'high' | 'medium' | 'low'
status: 'active' | 'pending' | 'blocked' status: 'active' | 'pending' | 'blocked'
progress: number // 0100 progress: number // 0100
detail?: string | null
source?: string
} }
/* ── Agent Detail Modal Types ─────────────────── */ /* ── Agent Detail Modal Types ─────────────────── */
@@ -26,6 +28,11 @@ export interface ThinkingItem {
ts: string ts: string
} }
export interface AgentActivityItem {
time: string
text: string
}
/** Dashboard view-model for an agent detail modal */ /** Dashboard view-model for an agent detail modal */
export interface AgentDetailData { export interface AgentDetailData {
id: string id: string
@@ -51,5 +58,6 @@ export interface AgentDetailData {
lastActive: string lastActive: string
activeTaskCount: number activeTaskCount: number
thinking: ThinkingItem[] thinking: ThinkingItem[]
activity: AgentActivityItem[]
availableModels: { id: string; alias: string }[] availableModels: { id: string; alias: string }[]
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import { icons } from '../../composables/icons'
defineProps<{ defineProps<{
connected?: boolean connected?: boolean
statusLabel?: string
}>() }>()
defineEmits<{ defineEmits<{
@@ -27,7 +28,7 @@ defineEmits<{
<!-- Status Pill --> <!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']"> <span :class="['pill', connected ? 'live' : 'preview']">
<span class="status-dot" :class="connected ? 'on' : 'off'"></span> <span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? 'OpenClaw verbunden' : 'Preview' }} {{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
</span> </span>
<!-- Ask Iris Button --> <!-- Ask Iris Button -->
+17 -1
View File
@@ -13,8 +13,20 @@ interface FlowBoardChatStore {
sendMessage: (text: string) => void sendMessage: (text: string) => void
} }
const STORAGE_KEY = 'nexus-flow-positions'
function readStoredPositions() {
if (typeof window === 'undefined') return {}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) as Record<string, { x: number; y: number }> : {}
} catch {
return {}
}
}
export function useFlowBoardState(agentStore: FlowBoardAgentStore, chatStore: FlowBoardChatStore) { export function useFlowBoardState(agentStore: FlowBoardAgentStore, chatStore: FlowBoardChatStore) {
const agentPositions = ref<Record<string, { x: number; y: number }>>({}) const agentPositions = ref<Record<string, { x: number; y: number }>>(readStoredPositions())
const enteringIds = ref<string[]>([]) const enteringIds = ref<string[]>([])
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool]) const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool])
@@ -46,10 +58,14 @@ export function useFlowBoardState(agentStore: FlowBoardAgentStore, chatStore: Fl
function resetLayout() { function resetLayout() {
agentPositions.value = {} agentPositions.value = {}
if (typeof window !== 'undefined') window.localStorage.removeItem(STORAGE_KEY)
} }
function updatePositions(positions: Record<string, { x: number; y: number }>) { function updatePositions(positions: Record<string, { x: number; y: number }>) {
agentPositions.value = { ...positions } agentPositions.value = { ...positions }
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(agentPositions.value))
}
} }
function sendChatMessage(text: string) { function sendChatMessage(text: string) {
+4 -3
View File
@@ -7,12 +7,12 @@
*/ */
import { ref } from 'vue' import { ref } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { useAgentStore } from '../stores/agents' import { useDashboardStore } from '../stores/dashboard'
import GalaxyBackground from '../components/background/GalaxyBackground.vue' import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.vue' import Sidebar from '../components/layout/Sidebar.vue'
import Topbar from '../components/layout/Topbar.vue' import Topbar from '../components/layout/Topbar.vue'
const agentStore = useAgentStore() const dashboardStore = useDashboardStore()
/* ── Mobile Sidebar State ───────────────────────── */ /* ── Mobile Sidebar State ───────────────────────── */
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
@@ -39,7 +39,8 @@ function closeMobileMenu() {
<main class="nexus-main"> <main class="nexus-main">
<Topbar <Topbar
:connected="agentStore.isConnected" :connected="dashboardStore.isGatewayConnected"
:status-label="dashboardStore.irisStatusLabel"
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen" @toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
/> />
<div class="nexus-content"> <div class="nexus-content">
+27 -2
View File
@@ -11,7 +11,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { apiFetch } from '../services/api' import { apiFetch } from '../services/api'
import type { AgentNodeData } from '../composables/useFlowLayout' import type { AgentNodeData } from '../composables/useFlowLayout'
import type { AgentDetailData, ThinkingItem } from '../components/dashboard/v2/types' import type { AgentActivityItem, AgentDetailData, ThinkingItem } from '../components/dashboard/v2/types'
/* ── API Response Shapes ──────────────────────────── */ /* ── API Response Shapes ──────────────────────────── */
@@ -40,6 +40,11 @@ interface ModelOption {
provider: string provider: string
} }
interface AgentActivityEntry {
time: string
text: string
}
/* ── Status Mapping ───────────────────────────────── */ /* ── Status Mapping ───────────────────────────────── */
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] { function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
@@ -148,6 +153,7 @@ export function buildAgentDetail(data: AgentNodeData, models: { id: string; alia
lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv', lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
activeTaskCount: data.task ? 1 : 0, activeTaskCount: data.task ? 1 : 0,
thinking: buildThinkingItems(data), thinking: buildThinkingItems(data),
activity: [],
availableModels: models, availableModels: models,
} }
} }
@@ -159,6 +165,7 @@ export const useAgentStore = defineStore('agents', {
loading: false, loading: false,
error: null as string | null, error: null as string | null,
selectedAgentId: null as string | null, selectedAgentId: null as string | null,
activityByAgentId: {} as Record<string, AgentActivityItem[]>,
refreshInterval: null as ReturnType<typeof setInterval> | null, refreshInterval: null as ReturnType<typeof setInterval> | null,
isConnected: false, isConnected: false,
}), }),
@@ -179,7 +186,10 @@ export const useAgentStore = defineStore('agents', {
if (!state.selectedAgentId) return null if (!state.selectedAgentId) return null
const data = state.agents.find(a => a.id === state.selectedAgentId) const data = state.agents.find(a => a.id === state.selectedAgentId)
if (!data) return null if (!data) return null
return buildAgentDetail(data, state.models) return {
...buildAgentDetail(data, state.models),
activity: state.activityByAgentId[data.id] ?? [],
}
}, },
/** Is the modal open? */ /** Is the modal open? */
@@ -249,9 +259,24 @@ export const useAgentStore = defineStore('agents', {
} }
}, },
async fetchAgentActivity(agentId: string) {
try {
const res = await apiFetch(`/api/dashboard/agents/${encodeURIComponent(agentId)}/activity?limit=5`)
if (!res.ok) return
const data: AgentActivityEntry[] = await res.json()
this.activityByAgentId[agentId] = data.map(entry => ({
time: entry.time,
text: entry.text,
}))
} catch (err) {
console.warn('[AgentStore] fetchAgentActivity failed', err)
}
},
/* ── Selection ───────────────────────────────── */ /* ── Selection ───────────────────────────────── */
selectAgent(id: string | null) { selectAgent(id: string | null) {
this.selectedAgentId = id this.selectedAgentId = id
if (id) void this.fetchAgentActivity(id)
}, },
/* ── Polling ─────────────────────────────────── */ /* ── Polling ─────────────────────────────────── */
+110
View File
@@ -0,0 +1,110 @@
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
interface DashboardStatusDto {
gatewayOk: boolean
irisStatus: string
activeAgents: number
pendingTasks: number
}
interface FeedEntryDto {
agent: string
action: string
timestamp: string
time: string
agentId?: string | null
type?: string | null
}
interface QueueItemDto {
id: string
name: string
status: string
priority: string
source: string
waitTime: string
}
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
status: null as DashboardStatusDto | null,
operations: [] as FeedEntryDto[],
queue: [] as QueueItemDto[],
loading: false,
error: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null,
}),
getters: {
isGatewayConnected: state => state.status?.gatewayOk ?? false,
irisStatusLabel: state => state.status?.irisStatus ?? 'Offline',
},
actions: {
async fetchStatus() {
try {
const res = await apiFetch('/api/dashboard/status')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.status = await res.json()
} catch (err) {
console.warn('[DashboardStore] fetchStatus failed', err)
this.status = null
}
},
async fetchOperations() {
try {
const res = await apiFetch('/api/dashboard/operations?limit=20')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.operations = await res.json()
} catch (err) {
console.warn('[DashboardStore] fetchOperations failed', err)
this.operations = []
}
},
async fetchQueue() {
try {
const res = await apiFetch('/api/dashboard/queue')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.queue = await res.json()
} catch (err) {
console.warn('[DashboardStore] fetchQueue failed', err)
this.queue = []
}
},
async refresh() {
this.loading = true
try {
await Promise.all([
this.fetchStatus(),
this.fetchOperations(),
this.fetchQueue(),
])
this.error = null
} catch (err) {
console.warn('[DashboardStore] refresh failed', err)
this.error = 'Dashboard metadata could not be loaded'
} finally {
this.loading = false
}
},
startPolling() {
if (this.refreshInterval) return
this.refresh()
this.refreshInterval = setInterval(() => {
this.refresh()
}, 30000)
},
stopPolling() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
},
},
})
+2
View File
@@ -56,6 +56,8 @@ function mapTask(t: DashboardTaskDto): TaskItem {
priority: mapPriority(t.priority), priority: mapPriority(t.priority),
status: mapState(t.state), status: mapState(t.state),
progress: mapProgress(t.state), progress: mapProgress(t.state),
detail: t.detail,
source: t.source,
} }
} }
+16 -1
View File
@@ -15,6 +15,7 @@
import { onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted } from 'vue'
import { useAgentStore } from '../../stores/agents' import { useAgentStore } from '../../stores/agents'
import { useChatStore } from '../../stores/chat' import { useChatStore } from '../../stores/chat'
import { useDashboardStore } from '../../stores/dashboard'
import { useTaskStore } from '../../stores/tasks' import { useTaskStore } from '../../stores/tasks'
import AlertBar from '../../components/dashboard/v2/AlertBar.vue' import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue' import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
@@ -26,6 +27,7 @@ import { useFlowBoardState } from '../../composables/useFlowBoardState'
/* ── Stores ──────────────────────────────────────── */ /* ── Stores ──────────────────────────────────────── */
const agentStore = useAgentStore() const agentStore = useAgentStore()
const chatStore = useChatStore() const chatStore = useChatStore()
const dashboardStore = useDashboardStore()
const taskStore = useTaskStore() const taskStore = useTaskStore()
const { const {
@@ -44,16 +46,28 @@ function handleBlockerClick() {
console.log('[FlowBoard] blocker clicked') console.log('[FlowBoard] blocker clicked')
} }
function blockerLabel() {
const blockedTask = taskStore.taskList.find(task => task.status === 'blocked')
if (!blockedTask) return undefined
return `${taskStore.taskList.filter(task => task.status === 'blocked').length} Blocker — ${blockedTask.title}`
}
function blockerCount() {
return taskStore.taskList.filter(task => task.status === 'blocked').length
}
/* ── Lifecycle ────────────────────────────────────── */ /* ── Lifecycle ────────────────────────────────────── */
onMounted(() => { onMounted(() => {
agentStore.startPolling() agentStore.startPolling()
chatStore.startPolling() chatStore.startPolling()
dashboardStore.startPolling()
taskStore.startPolling() taskStore.startPolling()
}) })
onUnmounted(() => { onUnmounted(() => {
agentStore.stopPolling() agentStore.stopPolling()
chatStore.stopPolling() chatStore.stopPolling()
dashboardStore.stopPolling()
taskStore.stopPolling() taskStore.stopPolling()
}) })
</script> </script>
@@ -68,9 +82,10 @@ onUnmounted(() => {
:active-count="agentStore.activeCount" :active-count="agentStore.activeCount"
:think-count="agentStore.thinkCount" :think-count="agentStore.thinkCount"
:idle-count="agentStore.idleCount" :idle-count="agentStore.idleCount"
:blocker-count="agentStore.blockerCount" :blocker-count="blockerCount()"
:today-cost="agentStore.todayCost" :today-cost="agentStore.todayCost"
:today-tokens="agentStore.todayTokens" :today-tokens="agentStore.todayTokens"
:blocker-label="blockerLabel()"
@blocker-click="handleBlockerClick" @blocker-click="handleBlockerClick"
/> />