286 lines
7.8 KiB
Vue
286 lines
7.8 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, watch, inject } from 'vue'
|
|
import { Panel, SplitPane, LogRenderer } from 'soleprint-ui'
|
|
import NotificationCard from '../components/NotificationCard.vue'
|
|
import HandoverBrief from '../components/HandoverBrief.vue'
|
|
import { useAgentEvents } from '../composables/useAgentEvents'
|
|
import { apiFetch } from '../config'
|
|
|
|
const flights = ref<any[]>([])
|
|
const selectedFlight = ref('')
|
|
const fceStatus = ref<'idle' | 'processing' | 'live' | 'error'>('idle')
|
|
const handoverStatus = ref<'idle' | 'processing' | 'live' | 'error'>('idle')
|
|
const notification = ref<any>(null)
|
|
const handoverBrief = ref<any>(null)
|
|
const scenarioVersion = inject<any>('scenarioVersion')
|
|
|
|
const showOps = inject<any>('showOps')
|
|
const showInternals = inject<any>('showInternals')
|
|
const { agentStatus, entries, graphNodes, currentRun, connect } = useAgentEvents()
|
|
|
|
// Connect WebSocket immediately so we don't miss events
|
|
onMounted(connect)
|
|
|
|
watch(scenarioVersion, () => {
|
|
loadFlights()
|
|
notification.value = null
|
|
handoverBrief.value = null
|
|
fceStatus.value = 'idle'
|
|
handoverStatus.value = 'idle'
|
|
})
|
|
|
|
async function loadFlights() {
|
|
const res = await apiFetch('/scenarios/data/flights')
|
|
const data = await res.json()
|
|
flights.value = data.map((f: any) => ({
|
|
id: f.flight_id,
|
|
label: `${f.flight_id} ${f.origin}→${f.destination}${f.status !== 'ON_TIME' ? ' (' + f.status + ')' : ''}`,
|
|
}))
|
|
selectedFlight.value = flights.value[0]?.id || ''
|
|
}
|
|
|
|
async function runFce() {
|
|
if (!selectedFlight.value) return
|
|
fceStatus.value = 'processing'
|
|
notification.value = null
|
|
if (!showInternals.value) showInternals.value = true
|
|
|
|
const res = await apiFetch('/agents/fce', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ flight_id: selectedFlight.value }),
|
|
})
|
|
const { run_id } = await res.json()
|
|
|
|
const poll = setInterval(async () => {
|
|
const r = await apiFetch(`/agents/runs/${run_id}`)
|
|
const data = await r.json()
|
|
if (data.status === 'completed') {
|
|
clearInterval(poll)
|
|
notification.value = data.result
|
|
fceStatus.value = 'live'
|
|
} else if (data.status === 'error') {
|
|
clearInterval(poll)
|
|
fceStatus.value = 'error'
|
|
}
|
|
}, 1000)
|
|
}
|
|
|
|
async function runHandover() {
|
|
handoverStatus.value = 'processing'
|
|
handoverBrief.value = null
|
|
if (!showInternals.value) showInternals.value = true
|
|
|
|
const res = await apiFetch('/agents/handover', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
})
|
|
const { run_id } = await res.json()
|
|
|
|
const poll = setInterval(async () => {
|
|
const r = await apiFetch(`/agents/runs/${run_id}`)
|
|
const data = await r.json()
|
|
if (data.status === 'completed') {
|
|
clearInterval(poll)
|
|
handoverBrief.value = data.result
|
|
handoverStatus.value = 'live'
|
|
} else if (data.status === 'error') {
|
|
clearInterval(poll)
|
|
handoverStatus.value = 'error'
|
|
}
|
|
}, 1000)
|
|
}
|
|
|
|
onMounted(loadFlights)
|
|
</script>
|
|
|
|
<template>
|
|
<div :class="['ops-layout', { split: showOps && showInternals }]">
|
|
<!-- Ops pane (left) -->
|
|
<div v-show="showOps" class="ops-pane">
|
|
<Panel title="FCE — Behind Every Departure" :status="fceStatus">
|
|
<template #actions>
|
|
<select v-model="selectedFlight" class="flight-select">
|
|
<option v-for="f in flights" :key="f.id" :value="f.id">{{ f.label }}</option>
|
|
</select>
|
|
<button class="run-btn" @click="runFce" :disabled="fceStatus === 'processing'">
|
|
{{ fceStatus === 'processing' ? 'Running...' : 'Run FCE' }}
|
|
</button>
|
|
</template>
|
|
<div v-if="notification" class="result-area"><NotificationCard :data="notification" /></div>
|
|
<div v-else-if="fceStatus === 'processing'" class="loading">Running agent...</div>
|
|
<div v-else class="empty">Select a flight and click Run FCE.</div>
|
|
</Panel>
|
|
|
|
<Panel title="Shift Handover Brief" :status="handoverStatus">
|
|
<template #actions>
|
|
<button class="run-btn" @click="runHandover" :disabled="handoverStatus === 'processing'">
|
|
{{ handoverStatus === 'processing' ? 'Running...' : 'Run Handover' }}
|
|
</button>
|
|
</template>
|
|
<div v-if="handoverBrief" class="result-area"><HandoverBrief :data="handoverBrief" /></div>
|
|
<div v-else-if="handoverStatus === 'processing'" class="loading">Running agent...</div>
|
|
<div v-else class="empty">Click Run Handover.</div>
|
|
</Panel>
|
|
</div>
|
|
|
|
<!-- Internals pane (right) -->
|
|
<div v-show="showInternals" class="internals-pane">
|
|
<Panel title="Agent Graph" :status="agentStatus">
|
|
<div class="graph-container">
|
|
<div v-if="graphNodes.length === 0" class="empty">Waiting for agent run...</div>
|
|
<div v-else class="graph-nodes">
|
|
<div v-for="node in graphNodes" :key="node.id" :class="['graph-node', node.status]">
|
|
<div class="node-dot"></div>
|
|
<span class="node-label">{{ node.id }}</span>
|
|
</div>
|
|
<div class="graph-edge-line"></div>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
<Panel title="Tool Call Stream" :status="agentStatus" class="stream-panel">
|
|
<LogRenderer :entries="entries" :auto-scroll="true" />
|
|
</Panel>
|
|
<div v-if="currentRun" class="run-summary">
|
|
{{ currentRun.agent }} / {{ currentRun.run_id }} / {{ entries.length }} events
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.ops-layout {
|
|
display: flex;
|
|
gap: 16px;
|
|
height: calc(100vh - 80px);
|
|
position: relative;
|
|
}
|
|
|
|
/* Both visible: 50/50 with divider */
|
|
.ops-layout.split > .ops-pane { flex: 1; }
|
|
.ops-layout.split > .internals-pane { flex: 1; border-left: var(--panel-border); padding-left: 16px; }
|
|
|
|
/* Single pane: full width */
|
|
.ops-layout > .internals-pane { flex: 1; }
|
|
.ops-layout > .ops-pane { flex: 1; }
|
|
|
|
.ops-pane {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 24px;
|
|
overflow: auto;
|
|
height: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.internals-pane {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
height: 100%;
|
|
overflow: auto;
|
|
min-width: 0;
|
|
}
|
|
|
|
.internals-pane > :first-child { flex-shrink: 0; }
|
|
.internals-pane > .stream-panel { flex: 1; min-height: 0; }
|
|
|
|
.run-summary {
|
|
padding: 4px 12px;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
|
|
|
|
.flight-select {
|
|
background: var(--surface-2);
|
|
color: var(--text-primary);
|
|
border: var(--panel-border);
|
|
padding: 4px 8px;
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.run-btn {
|
|
background: var(--accent);
|
|
color: white;
|
|
border: none;
|
|
padding: 4px 16px;
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.run-btn:hover { background: var(--accent-dim); }
|
|
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.result-area { padding: 16px; }
|
|
|
|
.loading, .empty {
|
|
padding: 32px;
|
|
text-align: center;
|
|
color: var(--text-dim);
|
|
font-family: var(--font-mono);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.loading { color: var(--accent); }
|
|
|
|
.graph-container { padding: 12px; }
|
|
|
|
.graph-nodes {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
position: relative;
|
|
padding-left: 12px;
|
|
}
|
|
|
|
.graph-edge-line {
|
|
position: absolute;
|
|
left: 17px;
|
|
top: 10px;
|
|
bottom: 10px;
|
|
width: 2px;
|
|
background: var(--surface-3);
|
|
}
|
|
|
|
.graph-node {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 6px 10px;
|
|
background: var(--surface-2);
|
|
border: var(--panel-border);
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
.node-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: var(--status-idle);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.graph-node.processing .node-dot {
|
|
background: var(--status-processing);
|
|
box-shadow: 0 0 8px var(--status-processing);
|
|
}
|
|
|
|
.graph-node.done .node-dot {
|
|
background: var(--status-live);
|
|
box-shadow: 0 0 8px var(--status-live);
|
|
}
|
|
|
|
.node-label {
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
}
|
|
</style>
|