init commit
This commit is contained in:
14
ui/app/index.html
Normal file
14
ui/app/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Stellar Air — NOVA</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
ui/app/package.json
Normal file
24
ui/app/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "united-ops-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"pinia": "^3.0.4",
|
||||
"soleprint-ui": "link:../framework",
|
||||
"uplot": "^1.6.32",
|
||||
"vue": "^3.5",
|
||||
"vue-router": "^4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5",
|
||||
"typescript": "^5.7",
|
||||
"vite": "^6.0"
|
||||
}
|
||||
}
|
||||
1098
ui/app/pnpm-lock.yaml
generated
Normal file
1098
ui/app/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
93
ui/app/src/App.vue
Normal file
93
ui/app/src/App.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import ScenarioSelector from './components/ScenarioSelector.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="app-header">
|
||||
<div class="app-title">
|
||||
<h1>STELLAR AIR</h1>
|
||||
<span class="app-subtitle">NOVA Operations Platform</span>
|
||||
</div>
|
||||
<nav class="app-nav">
|
||||
<router-link to="/" :class="{ active: route.path === '/' }">Operations</router-link>
|
||||
<router-link to="/internals" :class="{ active: route.path === '/internals' }">Internals</router-link>
|
||||
<router-link to="/data" :class="{ active: route.path === '/data' }">Data</router-link>
|
||||
</nav>
|
||||
<ScenarioSelector />
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 24px;
|
||||
background: var(--surface-1);
|
||||
border-bottom: var(--panel-border);
|
||||
}
|
||||
|
||||
.app-title h1 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.app-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.app-nav a {
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border: var(--panel-border);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.app-nav a:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.app-nav a.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
139
ui/app/src/components/HandoverBrief.vue
Normal file
139
ui/app/src/components/HandoverBrief.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
brief_text: string
|
||||
summary: { immediate_count: number; monitor_count: number; fyi_count: number }
|
||||
items: { immediate: string[]; monitor: string[]; fyi: string[] }
|
||||
generated_at: string
|
||||
duration_ms: number
|
||||
hubs: string[]
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="handover-brief">
|
||||
<div class="brief-header">
|
||||
<span class="brief-title">SHIFT HANDOVER BRIEF</span>
|
||||
<div class="summary-badges">
|
||||
<span v-if="data.summary.immediate_count" class="badge immediate">
|
||||
{{ data.summary.immediate_count }} IMMEDIATE
|
||||
</span>
|
||||
<span v-if="data.summary.monitor_count" class="badge monitor">
|
||||
{{ data.summary.monitor_count }} MONITOR
|
||||
</span>
|
||||
<span v-if="data.summary.fyi_count" class="badge fyi">
|
||||
{{ data.summary.fyi_count }} FYI
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brief-body">
|
||||
<section v-if="data.items.immediate.length" class="section immediate">
|
||||
<h3>━━━ IMMEDIATE ACTION ━━━</h3>
|
||||
<div v-for="(item, i) in data.items.immediate" :key="i" class="item">
|
||||
<span class="marker">▶</span> {{ item }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="data.items.monitor.length" class="section monitor">
|
||||
<h3>━━━ MONITOR ━━━</h3>
|
||||
<div v-for="(item, i) in data.items.monitor" :key="i" class="item">
|
||||
<span class="marker">⚠</span> {{ item }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="data.items.fyi.length" class="section fyi">
|
||||
<h3>━━━ FYI ━━━</h3>
|
||||
<div v-for="(item, i) in data.items.fyi" :key="i" class="item">
|
||||
<span class="marker">ℹ</span> {{ item }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="brief-footer">
|
||||
<span>Hubs: {{ data.hubs.join(', ') }}</span>
|
||||
<span>{{ data.duration_ms }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.handover-brief {
|
||||
background: var(--surface-1);
|
||||
border: var(--panel-border);
|
||||
}
|
||||
|
||||
.brief-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: var(--panel-border);
|
||||
}
|
||||
|
||||
.brief-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.summary-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.immediate { background: var(--status-error); color: var(--surface-0); }
|
||||
.badge.monitor { background: var(--status-warning); color: var(--surface-0); }
|
||||
.badge.fyi { background: var(--surface-3); color: var(--text-secondary); }
|
||||
|
||||
.brief-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section.immediate h3 { color: var(--status-error); }
|
||||
.section.monitor h3 { color: var(--status-warning); }
|
||||
.section.fyi h3 { color: var(--text-dim); }
|
||||
|
||||
.item {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
padding: 4px 0;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.brief-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-top: var(--panel-border);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
124
ui/app/src/components/NotificationCard.vue
Normal file
124
ui/app/src/components/NotificationCard.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
data: {
|
||||
flight_id: string
|
||||
status: string
|
||||
delay_minutes: number
|
||||
notification_text: string
|
||||
data_sources: string[]
|
||||
generated_at: string
|
||||
human_approved: boolean
|
||||
duration_ms: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const causeColor: Record<string, string> = {
|
||||
DELAYED: 'var(--status-warning)',
|
||||
CANCELLED: 'var(--status-error)',
|
||||
DIVERTED: 'var(--status-error)',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notification-card">
|
||||
<div class="card-header">
|
||||
<span class="flight-id">{{ data.flight_id }}</span>
|
||||
<span class="status-badge" :style="{ background: causeColor[data.status] || 'var(--status-idle)' }">
|
||||
{{ data.status }}
|
||||
<template v-if="data.delay_minutes"> {{ data.delay_minutes }}min</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="notification-text">{{ data.notification_text }}</pre>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="sources">
|
||||
<template v-for="(s, i) in data.data_sources" :key="s">
|
||||
<span :class="['source-tag', s.includes('live') ? 'live' : 'mock']">{{ s }}</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="meta">
|
||||
{{ data.duration_ms }}ms
|
||||
<span v-if="data.human_approved" class="approved">approved</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-card {
|
||||
background: var(--surface-1);
|
||||
border: var(--panel-border);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: var(--panel-border);
|
||||
}
|
||||
|
||||
.flight-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
color: var(--surface-0);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.notification-text {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-top: var(--panel-border);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
padding: 1px 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--surface-3);
|
||||
}
|
||||
|
||||
.source-tag.live {
|
||||
border-color: var(--status-live);
|
||||
color: var(--status-live);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.approved {
|
||||
color: var(--status-live);
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
66
ui/app/src/components/ScenarioSelector.vue
Normal file
66
ui/app/src/components/ScenarioSelector.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const scenarios = ref<any[]>([])
|
||||
const active = ref('')
|
||||
|
||||
async function loadScenarios() {
|
||||
const res = await fetch('/scenarios')
|
||||
scenarios.value = await res.json()
|
||||
const activeRes = await fetch('/scenarios/active')
|
||||
const activeData = await activeRes.json()
|
||||
active.value = activeData.scenario_id
|
||||
}
|
||||
|
||||
async function switchScenario(id: string) {
|
||||
await fetch('/scenarios/active', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scenario_id: id }),
|
||||
})
|
||||
active.value = id
|
||||
}
|
||||
|
||||
onMounted(loadScenarios)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scenario-selector">
|
||||
<label>Scenario:</label>
|
||||
<select :value="active" @change="switchScenario(($event.target as HTMLSelectElement).value)">
|
||||
<option v-for="s in scenarios" :key="s.scenario_id" :value="s.scenario_id">
|
||||
{{ s.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scenario-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
select {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-primary);
|
||||
border: var(--panel-border);
|
||||
padding: 4px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: 1px solid var(--accent);
|
||||
}
|
||||
</style>
|
||||
19
ui/app/src/main.ts
Normal file
19
ui/app/src/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import 'soleprint-ui/src/tokens.css'
|
||||
import './styles/mars-tokens.css'
|
||||
import App from './App.vue'
|
||||
import OpsNotifications from './pages/OpsNotifications.vue'
|
||||
import AgentInternals from './pages/AgentInternals.vue'
|
||||
import ScenarioData from './pages/ScenarioData.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: OpsNotifications },
|
||||
{ path: '/internals', component: AgentInternals },
|
||||
{ path: '/data', component: ScenarioData },
|
||||
],
|
||||
})
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
236
ui/app/src/pages/AgentInternals.vue
Normal file
236
ui/app/src/pages/AgentInternals.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { Panel, SplitPane, LogRenderer } from 'soleprint-ui'
|
||||
import type { LogEntry } from 'soleprint-ui'
|
||||
|
||||
const agentStatus = ref<'idle' | 'live' | 'processing' | 'error'>('idle')
|
||||
const entries = ref<LogEntry[]>([])
|
||||
const graphNodes = ref<{ id: string; status: string }[]>([])
|
||||
const currentRun = ref<{ agent: string; run_id: string } | null>(null)
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
|
||||
function connectWs() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
ws = new WebSocket(`${protocol}//${location.host}/ws/agent-events`)
|
||||
|
||||
ws.onopen = () => {
|
||||
agentStatus.value = 'live'
|
||||
}
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const event = JSON.parse(e.data)
|
||||
handleEvent(event)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
agentStatus.value = 'idle'
|
||||
setTimeout(connectWs, 3000)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
agentStatus.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function handleEvent(event: any) {
|
||||
const ts = event.timestamp || new Date().toISOString()
|
||||
const time = ts.split('T')[1]?.split('.')[0] || ts
|
||||
|
||||
switch (event.type) {
|
||||
case 'agent_start':
|
||||
currentRun.value = { agent: event.agent, run_id: event.run_id }
|
||||
agentStatus.value = 'processing'
|
||||
graphNodes.value = []
|
||||
entries.value = [{
|
||||
level: 'info',
|
||||
stage: 'system',
|
||||
msg: `Agent ${event.agent} started (${event.run_id})`,
|
||||
ts: time,
|
||||
}]
|
||||
break
|
||||
|
||||
case 'node_enter':
|
||||
graphNodes.value.push({ id: event.node, status: 'processing' })
|
||||
entries.value.push({
|
||||
level: 'info',
|
||||
stage: event.node,
|
||||
msg: `→ entering ${event.node}`,
|
||||
ts: time,
|
||||
})
|
||||
break
|
||||
|
||||
case 'node_exit':
|
||||
const node = graphNodes.value.find(n => n.id === event.node)
|
||||
if (node) node.status = 'done'
|
||||
break
|
||||
|
||||
case 'tool_call_end':
|
||||
const liveTag = event.is_live ? ' (live)' : ' (mock)'
|
||||
entries.value.push({
|
||||
level: 'info',
|
||||
stage: '',
|
||||
msg: `${event.tool} — ${event.latency_ms}ms ✓${liveTag}`,
|
||||
ts: time,
|
||||
})
|
||||
break
|
||||
|
||||
case 'tool_call_error':
|
||||
entries.value.push({
|
||||
level: 'error',
|
||||
stage: '',
|
||||
msg: `${event.tool} — FAILED: ${event.error}`,
|
||||
ts: time,
|
||||
})
|
||||
break
|
||||
|
||||
case 'agent_end':
|
||||
agentStatus.value = 'live'
|
||||
entries.value.push({
|
||||
level: 'info',
|
||||
stage: 'system',
|
||||
msg: `Agent complete: ${event.output_summary}`,
|
||||
ts: time,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(connectWs)
|
||||
onUnmounted(() => { ws?.close() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="internals-page">
|
||||
<SplitPane direction="horizontal" :initial-size="350" size-mode="px" :min="250" :max="500">
|
||||
<template #first>
|
||||
<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, i) in graphNodes"
|
||||
:key="node.id"
|
||||
:class="['graph-node', node.status]"
|
||||
>
|
||||
<div class="node-dot"></div>
|
||||
<span class="node-label">{{ node.id }}</span>
|
||||
</div>
|
||||
<div v-if="graphNodes.length" class="graph-edge-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
<template #second>
|
||||
<Panel title="Tool Call Stream" :status="agentStatus">
|
||||
<LogRenderer :entries="entries" :auto-scroll="true" />
|
||||
</Panel>
|
||||
</template>
|
||||
</SplitPane>
|
||||
|
||||
<Panel title="Run Summary" status="idle" class="summary-panel">
|
||||
<div v-if="currentRun" class="summary">
|
||||
<span>Agent: {{ currentRun.agent }}</span>
|
||||
<span>Run: {{ currentRun.run_id }}</span>
|
||||
<span>Events: {{ entries.length }}</span>
|
||||
<span>Nodes: {{ graphNodes.length }}</span>
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
Trigger an agent from the Operations page to see internals here.
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.internals-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
.internals-page > :first-child {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.graph-nodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.graph-edge-line {
|
||||
position: absolute;
|
||||
left: 17px;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
width: 2px;
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.graph-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-2);
|
||||
border: var(--panel-border);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.node-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
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: 13px;
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 8px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
175
ui/app/src/pages/OpsNotifications.vue
Normal file
175
ui/app/src/pages/OpsNotifications.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Panel } from 'soleprint-ui'
|
||||
import NotificationCard from '../components/NotificationCard.vue'
|
||||
import HandoverBrief from '../components/HandoverBrief.vue'
|
||||
|
||||
const flights = ref<any[]>([])
|
||||
const selectedFlight = ref('')
|
||||
const efhasStatus = ref<'idle' | 'processing' | 'live' | 'error'>('idle')
|
||||
const handoverStatus = ref<'idle' | 'processing' | 'live' | 'error'>('idle')
|
||||
const notification = ref<any>(null)
|
||||
const handoverBrief = ref<any>(null)
|
||||
|
||||
async function loadFlights() {
|
||||
// Load flights from active scenario via a simple status check on known IDs
|
||||
const res = await fetch('/scenarios/active')
|
||||
const scenario = await res.json()
|
||||
// Fetch flights by triggering the shared MCP server isn't exposed directly
|
||||
// so we'll use a hardcoded list per scenario for now
|
||||
// TODO: expose flight list via API
|
||||
flights.value = [
|
||||
{ id: 'UA432', label: 'UA432 ORD→SFO' },
|
||||
{ id: 'UA881', label: 'UA881 ORD→LAX' },
|
||||
{ id: 'UA233', label: 'UA233 ORD→DEN' },
|
||||
{ id: 'UA094', label: 'UA094 ORD→EWR' },
|
||||
{ id: 'UA517', label: 'UA517 ORD→IAH (CANCELLED)' },
|
||||
{ id: 'UA1220', label: 'UA1220 ORD→IAD (on-time)' },
|
||||
]
|
||||
selectedFlight.value = flights.value[0]?.id || ''
|
||||
}
|
||||
|
||||
async function runEfhas() {
|
||||
if (!selectedFlight.value) return
|
||||
efhasStatus.value = 'processing'
|
||||
notification.value = null
|
||||
|
||||
const res = await fetch('/agents/efhas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ flight_id: selectedFlight.value }),
|
||||
})
|
||||
const { run_id } = await res.json()
|
||||
|
||||
// Poll for result
|
||||
const poll = setInterval(async () => {
|
||||
const r = await fetch(`/agents/runs/${run_id}`)
|
||||
const data = await r.json()
|
||||
if (data.status === 'completed') {
|
||||
clearInterval(poll)
|
||||
notification.value = data.result
|
||||
efhasStatus.value = 'live'
|
||||
} else if (data.status === 'error') {
|
||||
clearInterval(poll)
|
||||
efhasStatus.value = 'error'
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function runHandover() {
|
||||
handoverStatus.value = 'processing'
|
||||
handoverBrief.value = null
|
||||
|
||||
const res = await fetch('/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 fetch(`/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-page">
|
||||
<Panel title="FCE — Behind Every Departure" :status="efhasStatus">
|
||||
<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="runEfhas" :disabled="efhasStatus === 'processing'">
|
||||
{{ efhasStatus === 'processing' ? 'Running...' : 'Run FCE' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="notification" class="result-area">
|
||||
<NotificationCard :data="notification" />
|
||||
</div>
|
||||
<div v-else-if="efhasStatus === 'processing'" class="loading">
|
||||
Running agent... gathering flight data, weather, crew notes...
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
Select a flight and click Run FCE to generate a notification.
|
||||
</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... scanning all hubs for active issues...
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
Click Run Handover to generate a shift handover brief.
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ops-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.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;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
387
ui/app/src/pages/ScenarioData.vue
Normal file
387
ui/app/src/pages/ScenarioData.vue
Normal file
@@ -0,0 +1,387 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Panel } from 'soleprint-ui'
|
||||
|
||||
const activeTab = ref<'flights' | 'crew' | 'notes' | 'maintenance' | 'rebookings'>('flights')
|
||||
const flights = ref<any[]>([])
|
||||
const crew = ref<any[]>([])
|
||||
const crewNotes = ref<Record<string, string[]>>({})
|
||||
const maintenance = ref<Record<string, any[]>>({})
|
||||
const rebookings = ref<any[]>([])
|
||||
|
||||
const editingFlight = ref<string | null>(null)
|
||||
const editingCrew = ref<string | null>(null)
|
||||
const editingNotes = ref<string | null>(null)
|
||||
const editNotesText = ref('')
|
||||
|
||||
async function loadAll() {
|
||||
const [f, c, n, m, r] = await Promise.all([
|
||||
fetch('/scenarios/data/flights').then(r => r.json()),
|
||||
fetch('/scenarios/data/crew').then(r => r.json()),
|
||||
fetch('/scenarios/data/crew-notes').then(r => r.json()),
|
||||
fetch('/scenarios/data/maintenance').then(r => r.json()),
|
||||
fetch('/scenarios/data/rebookings').then(r => r.json()),
|
||||
])
|
||||
flights.value = f
|
||||
crew.value = c
|
||||
crewNotes.value = n
|
||||
maintenance.value = m
|
||||
rebookings.value = r
|
||||
}
|
||||
|
||||
async function patchFlight(flight: any) {
|
||||
await fetch(`/scenarios/data/flights/${flight.flight_id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
delay_minutes: flight.delay_minutes,
|
||||
status: flight.status,
|
||||
gate: flight.gate,
|
||||
}),
|
||||
})
|
||||
editingFlight.value = null
|
||||
}
|
||||
|
||||
async function patchCrew(c: any) {
|
||||
const res = await fetch(`/scenarios/data/crew/${c.crew_id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ duty_hours_elapsed: c.duty_hours_elapsed }),
|
||||
})
|
||||
const updated = await res.json()
|
||||
const idx = crew.value.findIndex(x => x.crew_id === c.crew_id)
|
||||
if (idx >= 0) crew.value[idx] = updated
|
||||
editingCrew.value = null
|
||||
}
|
||||
|
||||
function startEditNotes(flightId: string) {
|
||||
editingNotes.value = flightId
|
||||
editNotesText.value = (crewNotes.value[flightId] || []).join('\n')
|
||||
}
|
||||
|
||||
async function saveNotes(flightId: string) {
|
||||
const notes = editNotesText.value.split('\n').filter(l => l.trim())
|
||||
await fetch(`/scenarios/data/crew-notes/${flightId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notes }),
|
||||
})
|
||||
crewNotes.value[flightId] = notes
|
||||
editingNotes.value = null
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
ON_TIME: 'var(--status-live)',
|
||||
DELAYED: 'var(--status-warning)',
|
||||
CANCELLED: 'var(--status-error)',
|
||||
DIVERTED: 'var(--status-error)',
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-page">
|
||||
<div class="tab-bar">
|
||||
<button v-for="tab in ['flights', 'crew', 'notes', 'maintenance', 'rebookings']"
|
||||
:key="tab" :class="{ active: activeTab === tab }" @click="activeTab = tab as any">
|
||||
{{ tab }}
|
||||
</button>
|
||||
<button class="reload-btn" @click="loadAll">reload</button>
|
||||
</div>
|
||||
|
||||
<!-- Flights -->
|
||||
<Panel v-if="activeTab === 'flights'" title="Flights" status="idle">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Flight</th><th>Route</th><th>Status</th><th>Delay</th>
|
||||
<th>Gate</th><th>Aircraft</th><th>Pax</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in flights" :key="f.flight_id">
|
||||
<td class="mono">{{ f.flight_id }}</td>
|
||||
<td class="mono">{{ f.origin }}→{{ f.destination }}</td>
|
||||
<td>
|
||||
<template v-if="editingFlight === f.flight_id">
|
||||
<select v-model="f.status" class="inline-input">
|
||||
<option v-for="s in ['ON_TIME','DELAYED','CANCELLED','DIVERTED']" :key="s">{{ s }}</option>
|
||||
</select>
|
||||
</template>
|
||||
<span v-else class="status-dot" :style="{ color: statusColors[f.status] }">{{ f.status }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="editingFlight === f.flight_id">
|
||||
<input v-model.number="f.delay_minutes" type="number" class="inline-input num" />
|
||||
</template>
|
||||
<template v-else>{{ f.delay_minutes }}min</template>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="editingFlight === f.flight_id">
|
||||
<input v-model="f.gate" class="inline-input short" />
|
||||
</template>
|
||||
<template v-else>{{ f.gate }}</template>
|
||||
</td>
|
||||
<td class="mono dim">{{ f.aircraft_tail }}</td>
|
||||
<td class="dim">{{ f.passenger_count }}</td>
|
||||
<td>
|
||||
<button v-if="editingFlight === f.flight_id" class="save-btn" @click="patchFlight(f)">save</button>
|
||||
<button v-else class="edit-btn" @click="editingFlight = f.flight_id">edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
|
||||
<!-- Crew -->
|
||||
<Panel v-if="activeTab === 'crew'" title="Crew" status="idle">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>Name</th><th>Role</th><th>Duty Elapsed</th>
|
||||
<th>Limit</th><th>Remaining</th><th>Status</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in crew" :key="c.crew_id" :class="{ 'at-risk': c.at_risk }">
|
||||
<td class="mono dim">{{ c.crew_id }}</td>
|
||||
<td>{{ c.name }}</td>
|
||||
<td class="mono">{{ c.role }}</td>
|
||||
<td>
|
||||
<template v-if="editingCrew === c.crew_id">
|
||||
<input v-model.number="c.duty_hours_elapsed" type="number" step="0.5" class="inline-input num" />
|
||||
</template>
|
||||
<template v-else>{{ c.duty_hours_elapsed }}h</template>
|
||||
</td>
|
||||
<td class="dim">{{ c.duty_hours_limit }}h</td>
|
||||
<td :style="{ color: c.at_risk ? 'var(--status-error)' : 'var(--text-secondary)' }">
|
||||
{{ c.hours_until_limit }}h
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="c.at_risk" class="risk-badge">AT RISK</span>
|
||||
</td>
|
||||
<td>
|
||||
<button v-if="editingCrew === c.crew_id" class="save-btn" @click="patchCrew(c)">save</button>
|
||||
<button v-else class="edit-btn" @click="editingCrew = c.crew_id">edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
|
||||
<!-- Crew Notes -->
|
||||
<Panel v-if="activeTab === 'notes'" title="Crew Notes" status="idle">
|
||||
<div v-if="Object.keys(crewNotes).length === 0" class="empty">No crew notes in this scenario.</div>
|
||||
<div v-for="(notes, flightId) in crewNotes" :key="flightId" class="notes-block">
|
||||
<div class="notes-header">
|
||||
<span class="mono">{{ flightId }}</span>
|
||||
<button v-if="editingNotes === flightId" class="save-btn" @click="saveNotes(flightId as string)">save</button>
|
||||
<button v-else class="edit-btn" @click="startEditNotes(flightId as string)">edit</button>
|
||||
</div>
|
||||
<template v-if="editingNotes === flightId">
|
||||
<textarea v-model="editNotesText" class="notes-editor" rows="5"></textarea>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ul class="notes-list">
|
||||
<li v-for="(note, i) in notes" :key="i">{{ note }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Maintenance -->
|
||||
<Panel v-if="activeTab === 'maintenance'" title="Maintenance (MEL Items)" status="idle">
|
||||
<div v-if="Object.keys(maintenance).length === 0" class="empty">No MEL items in this scenario.</div>
|
||||
<div v-for="(items, tail) in maintenance" :key="tail" class="notes-block">
|
||||
<div class="notes-header"><span class="mono">{{ tail }}</span></div>
|
||||
<div v-for="item in items" :key="item.mel_id" class="mel-item">
|
||||
<div><span class="mono dim">{{ item.mel_id }}</span> — <strong>{{ item.system }}</strong></div>
|
||||
<div class="dim">{{ item.description }}</div>
|
||||
<div v-if="item.restriction" class="restriction">{{ item.restriction }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Rebookings -->
|
||||
<Panel v-if="activeTab === 'rebookings'" title="Pending Rebookings" status="idle">
|
||||
<div v-if="rebookings.length === 0" class="empty">No pending rebookings in this scenario.</div>
|
||||
<table v-else class="data-table">
|
||||
<thead>
|
||||
<tr><th>Pax</th><th>Name</th><th>Status</th><th>Flight</th><th>Dest</th><th>Next</th><th>Urgency</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in rebookings" :key="r.pax_id">
|
||||
<td class="mono dim">{{ r.pax_id }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="mono">{{ r.mileage_plus_status }}</td>
|
||||
<td class="mono">{{ r.original_flight }}</td>
|
||||
<td class="mono">{{ r.destination }}</td>
|
||||
<td class="mono">{{ r.next_available || '—' }}</td>
|
||||
<td :style="{ color: r.urgency === 'HIGH' ? 'var(--status-error)' : r.urgency === 'MEDIUM' ? 'var(--status-warning)' : 'var(--text-dim)' }">
|
||||
{{ r.urgency }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.data-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tab-bar button {
|
||||
padding: 6px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
background: var(--surface-1);
|
||||
color: var(--text-secondary);
|
||||
border: var(--panel-border);
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.tab-bar button:hover { background: var(--surface-2); color: var(--text-primary); }
|
||||
.tab-bar button.active { background: var(--accent-dim); color: var(--text-primary); border-color: var(--accent); }
|
||||
|
||||
.reload-btn {
|
||||
margin-left: auto !important;
|
||||
color: var(--text-dim) !important;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-bottom: var(--panel-border);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid rgba(30, 42, 74, 0.4);
|
||||
}
|
||||
|
||||
.data-table tr:hover { background: var(--surface-2); }
|
||||
.data-table tr.at-risk { background: rgba(255, 61, 0, 0.08); }
|
||||
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.dim { color: var(--text-dim); }
|
||||
|
||||
.status-dot { font-family: var(--font-mono); font-size: 12px; font-weight: 600; }
|
||||
|
||||
.inline-input {
|
||||
background: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--accent);
|
||||
padding: 2px 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.inline-input.num { width: 60px; }
|
||||
.inline-input.short { width: 50px; }
|
||||
|
||||
.edit-btn, .save-btn {
|
||||
padding: 2px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
border: var(--panel-border);
|
||||
cursor: pointer;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.save-btn { border-color: var(--accent); color: var(--accent); }
|
||||
.edit-btn:hover { background: var(--surface-3); }
|
||||
.save-btn:hover { background: var(--accent-dim); }
|
||||
|
||||
.notes-block {
|
||||
padding: 12px 16px;
|
||||
border-bottom: var(--panel-border);
|
||||
}
|
||||
|
||||
.notes-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notes-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notes-list li {
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
border-left: 2px solid var(--surface-3);
|
||||
padding-left: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.notes-editor {
|
||||
width: 100%;
|
||||
background: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--accent);
|
||||
padding: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.mel-item {
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(30, 42, 74, 0.3);
|
||||
}
|
||||
|
||||
.restriction {
|
||||
margin-top: 4px;
|
||||
color: var(--status-warning);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
background: var(--status-error);
|
||||
color: var(--surface-0);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
62
ui/app/src/styles/mars-tokens.css
Normal file
62
ui/app/src/styles/mars-tokens.css
Normal file
@@ -0,0 +1,62 @@
|
||||
/* MARS theme — overrides framework tokens for United Ops aesthetic */
|
||||
|
||||
:root {
|
||||
/* Surfaces — deep navy */
|
||||
--surface-0: #0a0e17;
|
||||
--surface-1: #121829;
|
||||
--surface-2: #1a2340;
|
||||
--surface-3: #243056;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #e8eaf0;
|
||||
--text-secondary: #8892a8;
|
||||
--text-dim: #4a5568;
|
||||
|
||||
/* Accent — United blue */
|
||||
--accent: #0066ff;
|
||||
--accent-dim: #003d99;
|
||||
--accent-glow: rgba(0, 102, 255, 0.15);
|
||||
|
||||
/* Status */
|
||||
--status-idle: #4a5568;
|
||||
--status-live: #00c853;
|
||||
--status-processing: #0066ff;
|
||||
--status-error: #ff3d00;
|
||||
--status-warning: #ffc107;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-ui: 'Inter', sans-serif;
|
||||
|
||||
/* Panel */
|
||||
--panel-border: 1px solid #1e2a4a;
|
||||
--panel-radius: 0px;
|
||||
--panel-glow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--surface-0);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--surface-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
14
ui/app/tsconfig.json
Normal file
14
ui/app/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
17
ui/app/vite.config.ts
Normal file
17
ui/app/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/agents': 'http://localhost:8000',
|
||||
'/scenarios': 'http://localhost:8000',
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
25
ui/framework/package.json
Normal file
25
ui/framework/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "soleprint-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"pinia": "^2.2",
|
||||
"uplot": "^1.6",
|
||||
"vue": "^3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5",
|
||||
"typescript": "^5.6",
|
||||
"vite": "^6",
|
||||
"vitest": "^2",
|
||||
"vue-tsc": "^2"
|
||||
}
|
||||
}
|
||||
1692
ui/framework/pnpm-lock.yaml
generated
Normal file
1692
ui/framework/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
ui/framework/src/components/LayoutGrid.vue
Normal file
32
ui/framework/src/components/LayoutGrid.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
columns?: number
|
||||
rows?: number
|
||||
gap?: string
|
||||
}>(), {
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
gap: 'var(--space-2)',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="layout-grid"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${props.rows}, 1fr)`,
|
||||
gap: props.gap,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
87
ui/framework/src/components/Panel.vue
Normal file
87
ui/framework/src/components/Panel.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
status?: 'idle' | 'live' | 'processing' | 'error'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{{ title }}</span>
|
||||
<span class="panel-actions"><slot name="actions" /></span>
|
||||
<span class="panel-status" :class="status ?? 'idle'" />
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="panel-overlay">
|
||||
<slot name="overlay" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
position: relative;
|
||||
background: var(--surface-1);
|
||||
border: var(--panel-border);
|
||||
border-radius: var(--panel-radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: var(--panel-header-height);
|
||||
padding: 0 var(--space-3);
|
||||
background: var(--surface-2);
|
||||
border-bottom: var(--panel-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.panel-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.panel-status.idle { background: var(--status-idle); }
|
||||
.panel-status.live { background: var(--status-live); }
|
||||
.panel-status.processing { background: var(--status-processing); }
|
||||
.panel-status.error { background: var(--status-error); }
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: var(--space-2);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-overlay {
|
||||
position: absolute;
|
||||
inset: var(--panel-header-height) 0 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.panel-overlay > :deep(*) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
145
ui/framework/src/components/ParameterEditor.vue
Normal file
145
ui/framework/src/components/ParameterEditor.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface ConfigField {
|
||||
name: string
|
||||
type: string
|
||||
default: unknown
|
||||
description: string
|
||||
min: number | null
|
||||
max: number | null
|
||||
options: string[] | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ConfigField[]
|
||||
values: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update': [name: string, value: unknown]
|
||||
'reset': []
|
||||
}>()
|
||||
|
||||
const numericFields = computed(() => props.fields.filter(f => f.type === 'int' || f.type === 'float'))
|
||||
const boolFields = computed(() => props.fields.filter(f => f.type === 'bool'))
|
||||
|
||||
function onInput(name: string, value: unknown) {
|
||||
emit('update', name, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="param-editor">
|
||||
<!-- Boolean fields -->
|
||||
<label v-for="f in boolFields" :key="f.name" class="param-field bool-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="!!values[f.name]"
|
||||
@change="(e) => onInput(f.name, (e.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/_/g, ' ') }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Numeric fields (range sliders) -->
|
||||
<div v-for="f in numericFields" :key="f.name" class="param-field">
|
||||
<div class="field-header">
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/^edge_/, '').replace(/_/g, ' ') }}</span>
|
||||
<span class="field-value">{{ values[f.name] }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:min="f.min ?? 0"
|
||||
:max="f.max ?? 500"
|
||||
:step="f.type === 'float' ? 0.01 : 1"
|
||||
:value="values[f.name] as number"
|
||||
@input="(e) => onInput(f.name, Number((e.target as HTMLInputElement).value))"
|
||||
/>
|
||||
<div class="field-range">
|
||||
<span>{{ f.min ?? 0 }}</span>
|
||||
<span>{{ f.max ?? 500 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.param-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.param-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bool-field {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
color: var(--text-primary);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.field-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--surface-3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: #00bcd4;
|
||||
}
|
||||
</style>
|
||||
70
ui/framework/src/components/ResizeHandle.vue
Normal file
70
ui/framework/src/components/ResizeHandle.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
direction: 'horizontal' | 'vertical'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
resize: [delta: number]
|
||||
}>()
|
||||
|
||||
const dragging = ref(false)
|
||||
let startPos = 0
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
dragging.value = true
|
||||
startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||
const el = e.target as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
const currentPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||
const delta = currentPos - startPos
|
||||
startPos = currentPos
|
||||
emit('resize', delta)
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="resize-handle"
|
||||
:class="[direction, { dragging }]"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.resize-handle {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
touch-action: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle.dragging {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
.resize-handle.horizontal {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
margin: 0 -2px;
|
||||
}
|
||||
|
||||
.resize-handle.vertical {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
margin: -2px 0;
|
||||
}
|
||||
</style>
|
||||
157
ui/framework/src/components/SplitPane.vue
Normal file
157
ui/framework/src/components/SplitPane.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Split direction */
|
||||
direction?: 'horizontal' | 'vertical'
|
||||
/** Initial size of the sized pane (px or flex ratio) */
|
||||
initialSize?: number
|
||||
/** Size mode: 'px' = sized pane fixed in pixels, 'ratio' = flex ratio */
|
||||
sizeMode?: 'px' | 'ratio'
|
||||
/** Which pane is sized: 'first' or 'second'. Default: 'first'. */
|
||||
anchor?: 'first' | 'second'
|
||||
/** Min size (px in px-mode, ratio in ratio-mode) */
|
||||
min?: number
|
||||
/** Max size (px in px-mode, ratio in ratio-mode) */
|
||||
max?: number
|
||||
/** Whether the divider is draggable */
|
||||
resizable?: boolean
|
||||
}>(), {
|
||||
direction: 'horizontal',
|
||||
initialSize: 1,
|
||||
sizeMode: 'ratio',
|
||||
anchor: 'first',
|
||||
min: 0.1,
|
||||
max: 10,
|
||||
resizable: true,
|
||||
})
|
||||
|
||||
const size = ref(props.initialSize)
|
||||
const dragging = ref(false)
|
||||
let startPos = 0
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!props.resizable) return
|
||||
dragging.value = true
|
||||
startPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||
const el = e.target as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
const currentPos = props.direction === 'horizontal' ? e.clientX : e.clientY
|
||||
let delta = currentPos - startPos
|
||||
startPos = currentPos
|
||||
|
||||
// Dragging right/down grows first pane, shrinks second.
|
||||
// If anchor is 'second', invert so dragging grows the second pane.
|
||||
if (props.anchor === 'second') delta = -delta
|
||||
|
||||
if (props.sizeMode === 'px') {
|
||||
size.value = Math.max(props.min, Math.min(props.max, size.value + delta))
|
||||
} else {
|
||||
const scale = props.direction === 'horizontal' ? 0.01 : 0.02
|
||||
size.value = Math.max(props.min, Math.min(props.max, size.value + delta * scale))
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = false
|
||||
}
|
||||
|
||||
const isHorizontal = computed(() => props.direction === 'horizontal')
|
||||
|
||||
const sizedStyle = computed(() => {
|
||||
if (props.sizeMode === 'px') {
|
||||
const sizeStr = size.value + 'px'
|
||||
const minStr = props.min + 'px'
|
||||
return isHorizontal.value
|
||||
? { width: sizeStr, minWidth: minStr, flexShrink: '0' }
|
||||
: { height: sizeStr, minHeight: minStr, flexShrink: '0' }
|
||||
}
|
||||
return { flex: String(size.value) }
|
||||
})
|
||||
|
||||
const flexStyle = computed(() => ({ flex: '1' }))
|
||||
|
||||
const firstStyle = computed(() => props.anchor === 'first' ? sizedStyle.value : flexStyle.value)
|
||||
const secondStyle = computed(() => props.anchor === 'second' ? sizedStyle.value : flexStyle.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="split-pane" :class="[direction]">
|
||||
<div class="split-first" :style="firstStyle">
|
||||
<slot name="first" />
|
||||
</div>
|
||||
<div
|
||||
v-if="resizable"
|
||||
class="split-divider"
|
||||
:class="[direction, { dragging }]"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
/>
|
||||
<div class="split-second" :style="secondStyle">
|
||||
<slot name="second" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.split-pane {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-pane.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.split-pane.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split-first,
|
||||
.split-second {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Children must fill their pane */
|
||||
.split-first > :deep(*),
|
||||
.split-second > :deep(*) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.split-divider {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
touch-action: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.split-divider:hover,
|
||||
.split-divider.dragging {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
.split-divider.horizontal {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
margin: 0 -2px;
|
||||
}
|
||||
|
||||
.split-divider.vertical {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
margin: -2px 0;
|
||||
}
|
||||
</style>
|
||||
23
ui/framework/src/composables/useDataSource.ts
Normal file
23
ui/framework/src/composables/useDataSource.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||
import { DataSource, type DataSourceStatus } from '../datasources/DataSource'
|
||||
|
||||
/**
|
||||
* Composable that connects a component to a DataSource.
|
||||
*
|
||||
* Connects on mount, disconnects on unmount.
|
||||
* Returns reactive refs for data, status, and error.
|
||||
*/
|
||||
export function useDataSource<T = unknown>(source: DataSource<T>): {
|
||||
data: Ref<T | null>
|
||||
status: Ref<DataSourceStatus>
|
||||
error: Ref<string | null>
|
||||
} {
|
||||
onMounted(() => source.connect())
|
||||
onUnmounted(() => source.disconnect())
|
||||
|
||||
return {
|
||||
data: source.data as Ref<T | null>,
|
||||
status: source.status,
|
||||
error: source.error as Ref<string | null>,
|
||||
}
|
||||
}
|
||||
57
ui/framework/src/composables/useEditorExecution.ts
Normal file
57
ui/framework/src/composables/useEditorExecution.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface EditorExecutionOptions {
|
||||
/** Debounce delay in ms for auto-apply. Default: 150 */
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic editor execution pattern — debounced apply with auto-apply toggle,
|
||||
* loading/error/timing state tracking.
|
||||
*
|
||||
* The caller provides the actual execution function. This composable handles
|
||||
* the orchestration: debounce, auto-apply, loading state, timing.
|
||||
*/
|
||||
export function useEditorExecution(
|
||||
executeFn: () => Promise<void>,
|
||||
options: EditorExecutionOptions = {},
|
||||
) {
|
||||
const debounceMs = options.debounceMs ?? 150
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const autoApply = ref(true)
|
||||
const execTimeMs = ref<number | null>(null)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function apply() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
execTimeMs.value = null
|
||||
const t0 = performance.now()
|
||||
try {
|
||||
await executeFn()
|
||||
execTimeMs.value = Math.round(performance.now() - t0)
|
||||
} catch (e) {
|
||||
error.value = String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onParameterChange() {
|
||||
if (!autoApply.value) return
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => apply(), debounceMs)
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
autoApply,
|
||||
execTimeMs,
|
||||
apply,
|
||||
onParameterChange,
|
||||
}
|
||||
}
|
||||
77
ui/framework/src/composables/useRegistry.ts
Normal file
77
ui/framework/src/composables/useRegistry.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Generic registry composable — fetches typed data from a URL, caches it,
|
||||
* exposes it reactively.
|
||||
*
|
||||
* Use for any data that is loaded once at app init and rarely changes:
|
||||
* stage definitions, config schemas, available models, etc.
|
||||
*
|
||||
* The registry is shared across all consumers (singleton per URL).
|
||||
*/
|
||||
|
||||
const cache = new Map<string, { data: Ref<any>; loading: Ref<boolean>; error: Ref<string | null>; promise: Promise<void> | null }>()
|
||||
|
||||
export function useRegistry<T>(url: string): {
|
||||
data: Ref<T[]>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
refresh: () => Promise<void>
|
||||
} {
|
||||
if (!cache.has(url)) {
|
||||
const data = ref<T[]>([]) as Ref<T[]>
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const entry = { data, loading, error, promise: null as Promise<void> | null }
|
||||
cache.set(url, entry)
|
||||
|
||||
async function doFetch() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) {
|
||||
error.value = `Failed to fetch registry: ${resp.status}`
|
||||
return
|
||||
}
|
||||
data.value = await resp.json()
|
||||
} catch (e) {
|
||||
error.value = String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
entry.promise = doFetch()
|
||||
}
|
||||
|
||||
const entry = cache.get(url)!
|
||||
|
||||
async function refresh() {
|
||||
const data = entry.data
|
||||
const loading = entry.loading
|
||||
const error = entry.error
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) {
|
||||
error.value = `Failed to fetch registry: ${resp.status}`
|
||||
return
|
||||
}
|
||||
data.value = await resp.json()
|
||||
} catch (e) {
|
||||
error.value = String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: entry.data as Ref<T[]>,
|
||||
loading: entry.loading,
|
||||
error: entry.error,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
40
ui/framework/src/datasources/DataSource.ts
Normal file
40
ui/framework/src/datasources/DataSource.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { type Ref, ref } from 'vue'
|
||||
|
||||
export type DataSourceStatus = 'idle' | 'connecting' | 'live' | 'error'
|
||||
|
||||
/**
|
||||
* Base class for all data sources.
|
||||
*
|
||||
* A DataSource connects to some event stream, exposes reactive state,
|
||||
* and lets consumers subscribe to typed events. Panels read from these
|
||||
* reactively — they never touch the transport layer directly.
|
||||
*/
|
||||
export abstract class DataSource<T = unknown> {
|
||||
readonly id: string
|
||||
readonly data: Ref<T | null> = ref(null) as Ref<T | null>
|
||||
readonly status: Ref<DataSourceStatus> = ref('idle')
|
||||
readonly error: Ref<string | null> = ref(null) as Ref<string | null>
|
||||
|
||||
private listeners = new Map<string, Set<(payload: any) => void>>()
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id
|
||||
}
|
||||
|
||||
abstract connect(): void
|
||||
abstract disconnect(): void
|
||||
|
||||
/** Subscribe to a specific event type */
|
||||
on<P = unknown>(eventType: string, handler: (payload: P) => void): () => void {
|
||||
if (!this.listeners.has(eventType)) {
|
||||
this.listeners.set(eventType, new Set())
|
||||
}
|
||||
this.listeners.get(eventType)!.add(handler)
|
||||
return () => this.listeners.get(eventType)?.delete(handler)
|
||||
}
|
||||
|
||||
/** Emit an event to subscribers (called by subclasses) */
|
||||
protected emit(eventType: string, payload: unknown): void {
|
||||
this.listeners.get(eventType)?.forEach((fn) => fn(payload))
|
||||
}
|
||||
}
|
||||
93
ui/framework/src/datasources/SSEDataSource.ts
Normal file
93
ui/framework/src/datasources/SSEDataSource.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { DataSource } from './DataSource'
|
||||
|
||||
export interface SSEDataSourceOptions {
|
||||
/** Unique identifier for this source */
|
||||
id: string
|
||||
/** SSE endpoint URL (e.g. '/api/detect/stream/job-123') */
|
||||
url: string
|
||||
/** Event types to listen for. Each is dispatched to subscribers via on(). */
|
||||
eventTypes: string[]
|
||||
/** Max reconnection attempts before giving up. Default: 10 */
|
||||
maxRetries?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* DataSource backed by native EventSource (Server-Sent Events).
|
||||
*
|
||||
* Connects to a single SSE endpoint and demultiplexes events by type.
|
||||
* Multiple panels can subscribe to different event types from the same source.
|
||||
*/
|
||||
export class SSEDataSource extends DataSource {
|
||||
private es: EventSource | null = null
|
||||
private url: string
|
||||
private eventTypes: string[]
|
||||
private maxRetries: number
|
||||
private retryCount = 0
|
||||
|
||||
constructor(opts: SSEDataSourceOptions) {
|
||||
super(opts.id)
|
||||
this.url = opts.url
|
||||
this.eventTypes = opts.eventTypes
|
||||
this.maxRetries = opts.maxRetries ?? 10
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.es) return
|
||||
this.status.value = 'connecting'
|
||||
this.error.value = null
|
||||
|
||||
this.es = new EventSource(this.url)
|
||||
|
||||
this.es.onopen = () => {
|
||||
this.status.value = 'live'
|
||||
this.retryCount = 0
|
||||
}
|
||||
|
||||
this.es.onerror = () => {
|
||||
if (this.es?.readyState === EventSource.CLOSED) {
|
||||
this.retryCount++
|
||||
if (this.retryCount >= this.maxRetries) {
|
||||
this.status.value = 'error'
|
||||
this.error.value = `Connection lost after ${this.maxRetries} retries`
|
||||
this.disconnect()
|
||||
} else {
|
||||
this.status.value = 'connecting'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register a listener for each event type
|
||||
for (const eventType of this.eventTypes) {
|
||||
this.es.addEventListener(eventType, (e: MessageEvent) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.data)
|
||||
this.data.value = parsed
|
||||
this.emit(eventType, parsed)
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Terminal event — pipeline finished (success, failure, or cancel)
|
||||
this.es.addEventListener('done', () => {
|
||||
this.status.value = 'idle'
|
||||
})
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.es) {
|
||||
this.es.close()
|
||||
this.es = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the URL (e.g. when job ID changes) and reconnect */
|
||||
setUrl(url: string): void {
|
||||
this.url = url
|
||||
if (this.status.value === 'live' || this.status.value === 'connecting') {
|
||||
this.disconnect()
|
||||
this.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
45
ui/framework/src/datasources/StaticDataSource.ts
Normal file
45
ui/framework/src/datasources/StaticDataSource.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { DataSource } from './DataSource'
|
||||
|
||||
export interface StaticEvent {
|
||||
type: string
|
||||
data: unknown
|
||||
/** Delay in ms before emitting this event (relative to previous). Default: 0 */
|
||||
delay?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* DataSource that replays a fixture array of events.
|
||||
*
|
||||
* Used for development and testing without a running backend.
|
||||
* Events are emitted in sequence with optional delays.
|
||||
*/
|
||||
export class StaticDataSource extends DataSource {
|
||||
private events: StaticEvent[]
|
||||
private timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
constructor(id: string, events: StaticEvent[]) {
|
||||
super(id)
|
||||
this.events = events
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.status.value = 'live'
|
||||
this.error.value = null
|
||||
|
||||
let cumDelay = 0
|
||||
for (const event of this.events) {
|
||||
cumDelay += event.delay ?? 0
|
||||
const timeout = setTimeout(() => {
|
||||
this.data.value = event.data
|
||||
this.emit(event.type, event.data)
|
||||
}, cumDelay)
|
||||
this.timeouts.push(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
for (const t of this.timeouts) clearTimeout(t)
|
||||
this.timeouts = []
|
||||
this.status.value = 'idle'
|
||||
}
|
||||
}
|
||||
103
ui/framework/src/datasources/__tests__/StaticDataSource.test.ts
Normal file
103
ui/framework/src/datasources/__tests__/StaticDataSource.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { StaticDataSource } from '../StaticDataSource'
|
||||
|
||||
describe('StaticDataSource', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('emits events in order', async () => {
|
||||
const source = new StaticDataSource('test', [
|
||||
{ type: 'log', data: { msg: 'first' } },
|
||||
{ type: 'log', data: { msg: 'second' } },
|
||||
{ type: 'stats', data: { count: 42 } },
|
||||
])
|
||||
|
||||
const received: { type: string; data: unknown }[] = []
|
||||
source.on('log', (d) => received.push({ type: 'log', data: d }))
|
||||
source.on('stats', (d) => received.push({ type: 'stats', data: d }))
|
||||
|
||||
source.connect()
|
||||
|
||||
// Events with delay=0 fire on next microtask via setTimeout(0)
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
|
||||
expect(source.status.value).toBe('live')
|
||||
expect(received).toHaveLength(3)
|
||||
expect(received[0]).toEqual({ type: 'log', data: { msg: 'first' } })
|
||||
expect(received[1]).toEqual({ type: 'log', data: { msg: 'second' } })
|
||||
expect(received[2]).toEqual({ type: 'stats', data: { count: 42 } })
|
||||
|
||||
source.disconnect()
|
||||
expect(source.status.value).toBe('idle')
|
||||
})
|
||||
|
||||
it('respects delays between events', async () => {
|
||||
const source = new StaticDataSource('test-delay', [
|
||||
{ type: 'a', data: 1 },
|
||||
{ type: 'b', data: 2, delay: 50 },
|
||||
])
|
||||
|
||||
const received: unknown[] = []
|
||||
source.on('a', (d) => received.push(d))
|
||||
source.on('b', (d) => received.push(d))
|
||||
|
||||
source.connect()
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
expect(received).toHaveLength(1) // only 'a' so far
|
||||
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
expect(received).toHaveLength(2) // 'b' arrived after delay
|
||||
|
||||
source.disconnect()
|
||||
})
|
||||
|
||||
it('updates data ref with latest event payload', async () => {
|
||||
const source = new StaticDataSource('test-data', [
|
||||
{ type: 'x', data: { v: 1 } },
|
||||
{ type: 'x', data: { v: 2 } },
|
||||
])
|
||||
|
||||
source.connect()
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
|
||||
expect(source.data.value).toEqual({ v: 2 })
|
||||
|
||||
source.disconnect()
|
||||
})
|
||||
|
||||
it('cleans up on disconnect', async () => {
|
||||
const source = new StaticDataSource('test-cleanup', [
|
||||
{ type: 'a', data: 1 },
|
||||
{ type: 'b', data: 2, delay: 100 },
|
||||
])
|
||||
|
||||
const received: unknown[] = []
|
||||
source.on('b', (d) => received.push(d))
|
||||
|
||||
source.connect()
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
source.disconnect()
|
||||
|
||||
// 'b' should never fire since we disconnected before its delay
|
||||
await new Promise((r) => setTimeout(r, 150))
|
||||
expect(received).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unsubscribe removes listener', async () => {
|
||||
const source = new StaticDataSource('test-unsub', [
|
||||
{ type: 'x', data: 1 },
|
||||
])
|
||||
|
||||
const received: unknown[] = []
|
||||
const unsub = source.on('x', (d) => received.push(d))
|
||||
unsub()
|
||||
|
||||
source.connect()
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
|
||||
expect(received).toHaveLength(0)
|
||||
source.disconnect()
|
||||
})
|
||||
})
|
||||
38
ui/framework/src/index.ts
Normal file
38
ui/framework/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Framework public API
|
||||
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
|
||||
export { SSEDataSource } from './datasources/SSEDataSource'
|
||||
export { StaticDataSource } from './datasources/StaticDataSource'
|
||||
export { useDataSource } from './composables/useDataSource'
|
||||
export { useRegistry } from './composables/useRegistry'
|
||||
export { useEditorExecution } from './composables/useEditorExecution'
|
||||
export type { EditorExecutionOptions } from './composables/useEditorExecution'
|
||||
|
||||
// Components
|
||||
export { default as Panel } from './components/Panel.vue'
|
||||
export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
||||
export { default as ResizeHandle } from './components/ResizeHandle.vue'
|
||||
export { default as SplitPane } from './components/SplitPane.vue'
|
||||
export { default as ParameterEditor } from './components/ParameterEditor.vue'
|
||||
export type { ConfigField } from './components/ParameterEditor.vue'
|
||||
|
||||
// Renderers
|
||||
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||
export { default as TimeSeriesRenderer } from './renderers/TimeSeriesRenderer.vue'
|
||||
export { default as GraphRenderer } from './renderers/GraphRenderer.vue'
|
||||
export { default as FrameRenderer } from './renderers/FrameRenderer.vue'
|
||||
export { default as TableRenderer } from './renderers/TableRenderer.vue'
|
||||
|
||||
// Renderer types
|
||||
export type { FrameBBox, FrameOverlay } from './renderers/FrameRenderer.vue'
|
||||
export type { LogEntry } from './renderers/LogRenderer.vue'
|
||||
export type { GraphNode, GraphMode } from './renderers/GraphRenderer.vue'
|
||||
export type { TableColumn } from './renderers/TableRenderer.vue'
|
||||
export type { TimeSeriesSeries } from './renderers/TimeSeriesRenderer.vue'
|
||||
|
||||
// Interaction plugins
|
||||
export type { InteractionPlugin, PluginContext } from './plugins/InteractionPlugin'
|
||||
export { BBoxDrawPlugin } from './plugins/BBoxDrawPlugin'
|
||||
export type { BBoxResult, BBoxCallback } from './plugins/BBoxDrawPlugin'
|
||||
export { CrosshairPlugin } from './plugins/CrosshairPlugin'
|
||||
export type { CrosshairCallback } from './plugins/CrosshairPlugin'
|
||||
|
||||
88
ui/framework/src/plugins/BBoxDrawPlugin.ts
Normal file
88
ui/framework/src/plugins/BBoxDrawPlugin.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* BBoxDrawPlugin — draw bounding boxes on the frame viewer.
|
||||
*
|
||||
* User drags on the canvas to draw a rectangle.
|
||||
* On pointer up, emits the bbox coordinates via the callback.
|
||||
* The frame viewer panel feeds this into the selection store.
|
||||
*/
|
||||
|
||||
import type { InteractionPlugin, PluginContext } from './InteractionPlugin'
|
||||
|
||||
export interface BBoxResult {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export type BBoxCallback = (bbox: BBoxResult) => void
|
||||
|
||||
export class BBoxDrawPlugin implements InteractionPlugin {
|
||||
name = 'bbox-draw'
|
||||
|
||||
private ctx: CanvasRenderingContext2D | null = null
|
||||
private drawing = false
|
||||
private startX = 0
|
||||
private startY = 0
|
||||
private currentBox: BBoxResult | null = null
|
||||
private callback: BBoxCallback
|
||||
|
||||
constructor(callback: BBoxCallback) {
|
||||
this.callback = callback
|
||||
}
|
||||
|
||||
onMount(context: PluginContext): void {
|
||||
this.ctx = context.ctx
|
||||
}
|
||||
|
||||
onUnmount(): void {
|
||||
this.ctx = null
|
||||
this.drawing = false
|
||||
this.currentBox = null
|
||||
}
|
||||
|
||||
onPointerDown(e: PointerEvent): void {
|
||||
this.drawing = true
|
||||
this.startX = e.offsetX
|
||||
this.startY = e.offsetY
|
||||
this.currentBox = null
|
||||
}
|
||||
|
||||
onPointerMove(e: PointerEvent): void {
|
||||
if (!this.drawing) return
|
||||
|
||||
const x = Math.min(this.startX, e.offsetX)
|
||||
const y = Math.min(this.startY, e.offsetY)
|
||||
const w = Math.abs(e.offsetX - this.startX)
|
||||
const h = Math.abs(e.offsetY - this.startY)
|
||||
|
||||
this.currentBox = { x, y, w, h }
|
||||
}
|
||||
|
||||
onPointerUp(_e: PointerEvent): void {
|
||||
if (!this.drawing) return
|
||||
this.drawing = false
|
||||
|
||||
if (this.currentBox && this.currentBox.w > 5 && this.currentBox.h > 5) {
|
||||
this.callback(this.currentBox)
|
||||
}
|
||||
|
||||
this.currentBox = null
|
||||
}
|
||||
|
||||
render(ctx: CanvasRenderingContext2D): void {
|
||||
if (!this.currentBox) return
|
||||
|
||||
const box = this.currentBox
|
||||
|
||||
ctx.strokeStyle = '#4f9cf9'
|
||||
ctx.lineWidth = 2
|
||||
ctx.setLineDash([6, 3])
|
||||
ctx.strokeRect(box.x, box.y, box.w, box.h)
|
||||
ctx.setLineDash([])
|
||||
|
||||
// Semi-transparent fill
|
||||
ctx.fillStyle = 'rgba(79, 156, 249, 0.1)'
|
||||
ctx.fillRect(box.x, box.y, box.w, box.h)
|
||||
}
|
||||
}
|
||||
60
ui/framework/src/plugins/CrosshairPlugin.ts
Normal file
60
ui/framework/src/plugins/CrosshairPlugin.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* CrosshairPlugin — synchronized vertical crosshair across time-series panels.
|
||||
*
|
||||
* When the user hovers on any panel with this plugin, the crosshair
|
||||
* position (as a timestamp) is written to the selection store.
|
||||
* All panels with this plugin render a vertical line at that timestamp.
|
||||
*/
|
||||
|
||||
import type { InteractionPlugin, PluginContext } from './InteractionPlugin'
|
||||
|
||||
export type CrosshairCallback = (timestamp: number | null) => void
|
||||
|
||||
export class CrosshairPlugin implements InteractionPlugin {
|
||||
name = 'crosshair'
|
||||
|
||||
private width = 0
|
||||
private callback: CrosshairCallback
|
||||
|
||||
/** Current crosshair X position (pixels), set externally from store */
|
||||
public crosshairX: number | null = null
|
||||
|
||||
constructor(callback: CrosshairCallback) {
|
||||
this.callback = callback
|
||||
}
|
||||
|
||||
onMount(context: PluginContext): void {
|
||||
this.width = context.width
|
||||
}
|
||||
|
||||
onUnmount(): void {
|
||||
this.crosshairX = null
|
||||
}
|
||||
|
||||
onPointerMove(e: PointerEvent): void {
|
||||
// Convert pixel X to normalized position (0-1)
|
||||
const normalized = e.offsetX / this.width
|
||||
this.callback(normalized)
|
||||
}
|
||||
|
||||
onPointerDown(_e: PointerEvent): void {
|
||||
// no-op for crosshair
|
||||
}
|
||||
|
||||
onPointerUp(_e: PointerEvent): void {
|
||||
this.callback(null)
|
||||
}
|
||||
|
||||
render(ctx: CanvasRenderingContext2D): void {
|
||||
if (this.crosshairX === null) return
|
||||
|
||||
ctx.strokeStyle = '#a78bfa'
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash([4, 4])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(this.crosshairX, 0)
|
||||
ctx.lineTo(this.crosshairX, ctx.canvas.height)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
}
|
||||
36
ui/framework/src/plugins/InteractionPlugin.ts
Normal file
36
ui/framework/src/plugins/InteractionPlugin.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Interaction plugin interface.
|
||||
*
|
||||
* Plugins attach to a Panel's overlay canvas. They receive pointer events
|
||||
* and emit typed results via the callback. The panel handles rendering
|
||||
* the overlay and routing events to the active plugin.
|
||||
*/
|
||||
|
||||
export interface PluginContext {
|
||||
/** Canvas element for drawing overlays */
|
||||
canvas: HTMLCanvasElement
|
||||
/** 2D rendering context */
|
||||
ctx: CanvasRenderingContext2D
|
||||
/** Canvas dimensions (may differ from display size) */
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface InteractionPlugin {
|
||||
/** Unique plugin name */
|
||||
name: string
|
||||
|
||||
/** Called when the plugin is mounted on a panel */
|
||||
onMount(context: PluginContext): void
|
||||
|
||||
/** Called when the plugin is unmounted */
|
||||
onUnmount(): void
|
||||
|
||||
/** Pointer event handlers (optional) */
|
||||
onPointerDown?(e: PointerEvent): void
|
||||
onPointerMove?(e: PointerEvent): void
|
||||
onPointerUp?(e: PointerEvent): void
|
||||
|
||||
/** Called each animation frame to render the overlay */
|
||||
render(ctx: CanvasRenderingContext2D): void
|
||||
}
|
||||
178
ui/framework/src/renderers/FrameRenderer.vue
Normal file
178
ui/framework/src/renderers/FrameRenderer.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
|
||||
export interface FrameBBox {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
confidence: number
|
||||
label: string
|
||||
resolved_brand?: string | null
|
||||
source?: string | null
|
||||
stage?: string | null
|
||||
ocr_text?: string | null
|
||||
}
|
||||
|
||||
export interface FrameOverlay {
|
||||
/** Base64 encoded image (same dimensions as main image) */
|
||||
src: string
|
||||
label: string
|
||||
visible: boolean
|
||||
/** Opacity 0-1, default 0.5 */
|
||||
opacity?: number
|
||||
/** Image format — 'jpeg' (default) or 'png' (supports transparency) */
|
||||
srcFormat?: 'jpeg' | 'png'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Base64 JPEG image */
|
||||
imageSrc: string
|
||||
/** Bounding boxes to overlay */
|
||||
boxes: FrameBBox[]
|
||||
/** Debug overlay layers (edge images, line visualizations, etc.) */
|
||||
overlays?: FrameOverlay[]
|
||||
}>()
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
|
||||
function draw() {
|
||||
const cvs = canvas.value
|
||||
const ctr = container.value
|
||||
if (!cvs || !ctr || !props.imageSrc) return
|
||||
|
||||
const ctx = cvs.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const img = new window.Image()
|
||||
img.onload = () => {
|
||||
cvs.width = ctr.clientWidth
|
||||
cvs.height = ctr.clientHeight
|
||||
|
||||
const scale = Math.min(cvs.width / img.width, cvs.height / img.height)
|
||||
const dx = (cvs.width - img.width * scale) / 2
|
||||
const dy = (cvs.height - img.height * scale) / 2
|
||||
|
||||
ctx.clearRect(0, 0, cvs.width, cvs.height)
|
||||
ctx.drawImage(img, dx, dy, img.width * scale, img.height * scale)
|
||||
|
||||
// Draw debug overlays (edge images, line visualizations)
|
||||
drawOverlays(ctx, dx, dy, img.width * scale, img.height * scale)
|
||||
|
||||
// Draw bounding boxes on top
|
||||
for (const box of props.boxes) {
|
||||
const bx = dx + box.x * scale
|
||||
const by = dy + box.y * scale
|
||||
const bw = box.w * scale
|
||||
const bh = box.h * scale
|
||||
|
||||
const color = sourceColor(box)
|
||||
const resolved = box.resolved_brand || box.ocr_text
|
||||
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 2
|
||||
if (!resolved) {
|
||||
ctx.setLineDash([4, 3])
|
||||
}
|
||||
ctx.strokeRect(bx, by, bw, bh)
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
}
|
||||
img.src = `data:image/jpeg;base64,${props.imageSrc}`
|
||||
}
|
||||
|
||||
/** Pending overlay images that need async loading */
|
||||
const overlayCache = new Map<string, HTMLImageElement>()
|
||||
|
||||
function drawOverlays(ctx: CanvasRenderingContext2D, dx: number, dy: number, dw: number, dh: number) {
|
||||
const layers = props.overlays ?? []
|
||||
for (const layer of layers) {
|
||||
if (!layer.visible || !layer.src) continue
|
||||
|
||||
const cached = overlayCache.get(layer.src)
|
||||
if (cached && cached.complete) {
|
||||
ctx.globalAlpha = layer.opacity ?? 0.5
|
||||
ctx.drawImage(cached, dx, dy, dw, dh)
|
||||
ctx.globalAlpha = 1.0
|
||||
} else if (!cached) {
|
||||
// Load async, redraw when ready
|
||||
const overlay = new window.Image()
|
||||
overlay.onload = () => draw()
|
||||
overlay.src = `data:image/${layer.srcFormat ?? 'jpeg'};base64,${layer.src}`
|
||||
overlayCache.set(layer.src, overlay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
yolo: '#f5a623', // yellow — raw detection
|
||||
ocr: '#ff8c42', // orange — text extracted
|
||||
ocr_matched: '#3ecf8e', // green — brand resolved
|
||||
local_vlm: '#4f9cf9', // blue — VLM resolved
|
||||
cloud_llm: '#a78bfa', // purple — cloud resolved
|
||||
unresolved: '#e05252', // red — nothing matched
|
||||
}
|
||||
|
||||
// CV region labels — distinct from source-based colors
|
||||
const REGION_COLORS: Record<string, string> = {
|
||||
edge_region: '#00bcd4', // cyan
|
||||
contour_region: '#ffd54f', // yellow
|
||||
color_region: '#e040fb', // magenta
|
||||
candidate: '#4caf50', // green — passed readability
|
||||
rejected: '#e05252', // red — failed readability
|
||||
}
|
||||
|
||||
function sourceColor(box: FrameBBox): string {
|
||||
if (REGION_COLORS[box.label]) return REGION_COLORS[box.label]
|
||||
if (box.resolved_brand) return SOURCE_COLORS.ocr_matched
|
||||
if (box.source && SOURCE_COLORS[box.source]) return SOURCE_COLORS[box.source]
|
||||
return confidenceColor(box.confidence)
|
||||
}
|
||||
|
||||
function confidenceColor(conf: number): string {
|
||||
if (conf >= 0.7) return 'var(--conf-high)'
|
||||
if (conf >= 0.4) return 'var(--conf-mid)'
|
||||
return 'var(--conf-low)'
|
||||
}
|
||||
|
||||
watch(() => [props.imageSrc, props.boxes, props.overlays], () => nextTick(draw), { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(draw)
|
||||
const observer = new ResizeObserver(() => draw())
|
||||
if (container.value) observer.observe(container.value)
|
||||
onUnmounted(() => observer.disconnect())
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="frame-renderer">
|
||||
<canvas ref="canvas" />
|
||||
<div v-if="!imageSrc" class="frame-empty">No frame</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.frame-renderer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.frame-renderer canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.frame-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
317
ui/framework/src/renderers/GraphRenderer.vue
Normal file
317
ui/framework/src/renderers/GraphRenderer.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { VueFlow } from '@vue-flow/core'
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
status: 'pending' | 'running' | 'done' | 'error' | 'skipped' | 'placeholder'
|
||||
/** Whether a checkpoint exists at this stage */
|
||||
hasCheckpoint?: boolean
|
||||
/** Stage category (e.g. 'cv', 'ai', 'preprocessing') */
|
||||
category?: string
|
||||
/** Which editors are available for this stage */
|
||||
availableEditors?: string[]
|
||||
}
|
||||
|
||||
export type GraphMode = 'observe' | 'edit-in-pipeline' | 'edit-isolated'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
nodes: GraphNode[]
|
||||
/** Interaction mode — changes visual treatment and click behavior */
|
||||
mode?: GraphMode
|
||||
/** Currently edited stage (highlighted in edit modes) */
|
||||
activeStage?: string | null
|
||||
/** Stages that have a region editor (bbox/polygon) */
|
||||
regionStages?: string[]
|
||||
}>(), {
|
||||
mode: 'observe',
|
||||
activeStage: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-region-editor': [stage: string]
|
||||
'open-stage-editor': [stage: string]
|
||||
'node-click': [stage: string]
|
||||
}>()
|
||||
|
||||
const regionStageSet = computed(() => new Set(props.regionStages ?? []))
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'var(--status-idle)',
|
||||
running: 'var(--status-processing)',
|
||||
done: 'var(--status-live)',
|
||||
error: 'var(--status-error)',
|
||||
skipped: '#4a6fa5',
|
||||
placeholder: 'transparent',
|
||||
}
|
||||
|
||||
function nodeAppearance(node: GraphNode) {
|
||||
const isActive = node.id === props.activeStage
|
||||
const mode = props.mode
|
||||
|
||||
// Edit-isolated: only the active node is fully visible
|
||||
if (mode === 'edit-isolated' && !isActive) {
|
||||
return {
|
||||
color: 'var(--surface-3)',
|
||||
textColor: 'var(--text-dim)',
|
||||
opacity: 0.5,
|
||||
outline: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Edit-in-pipeline: active node highlighted, upstream dimmed, downstream normal
|
||||
if (mode === 'edit-in-pipeline' && props.activeStage) {
|
||||
const activeIdx = props.nodes.findIndex(n => n.id === props.activeStage)
|
||||
const nodeIdx = props.nodes.findIndex(n => n.id === node.id)
|
||||
|
||||
if (isActive) {
|
||||
return {
|
||||
color: 'var(--status-processing)',
|
||||
textColor: '#fff',
|
||||
opacity: 1,
|
||||
outline: true,
|
||||
}
|
||||
}
|
||||
if (nodeIdx < activeIdx) {
|
||||
// Upstream: frozen from checkpoint
|
||||
return {
|
||||
color: 'var(--surface-3)',
|
||||
textColor: 'var(--text-secondary)',
|
||||
opacity: 0.7,
|
||||
outline: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder: hollow, no text
|
||||
if (node.status === 'placeholder') {
|
||||
return {
|
||||
color: 'transparent',
|
||||
textColor: 'transparent',
|
||||
opacity: 0.6,
|
||||
outline: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Default: observe mode or downstream in edit-in-pipeline
|
||||
return {
|
||||
color: STATUS_COLORS[node.status] ?? STATUS_COLORS.pending,
|
||||
textColor: '#fff',
|
||||
opacity: 1,
|
||||
outline: isActive,
|
||||
}
|
||||
}
|
||||
|
||||
const flowNodes = computed(() =>
|
||||
props.nodes.map((n, i) => {
|
||||
const appearance = nodeAppearance(n)
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'stage',
|
||||
position: { x: 20, y: i * 80 },
|
||||
data: {
|
||||
label: n.id.replace(/_/g, ' '),
|
||||
status: n.status,
|
||||
...appearance,
|
||||
hasCheckpoint: n.hasCheckpoint ?? false,
|
||||
hasStageEditor: regionStageSet.value.has(n.id),
|
||||
isRunning: n.status === 'running',
|
||||
isActive: n.id === props.activeStage,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const flowEdges = computed(() => {
|
||||
const edges = []
|
||||
for (let i = 0; i < props.nodes.length - 1; i++) {
|
||||
const isActiveEdge = props.mode !== 'observe' && props.activeStage
|
||||
&& props.nodes.findIndex(n => n.id === props.activeStage) > i
|
||||
|
||||
edges.push({
|
||||
id: `${props.nodes[i].id}->${props.nodes[i + 1].id}`,
|
||||
source: props.nodes[i].id,
|
||||
target: props.nodes[i + 1].id,
|
||||
animated: props.nodes[i].status === 'running',
|
||||
style: {
|
||||
stroke: isActiveEdge ? 'var(--text-dim)' : '#555568',
|
||||
strokeDasharray: isActiveEdge ? '4 4' : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
return edges
|
||||
})
|
||||
|
||||
function onNodeClick(id: string) {
|
||||
emit('node-click', id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="graph-renderer">
|
||||
<VueFlow
|
||||
:nodes="flowNodes"
|
||||
:edges="flowEdges"
|
||||
:fit-view-on-init="true"
|
||||
:nodes-draggable="false"
|
||||
:nodes-connectable="false"
|
||||
:zoom-on-scroll="false"
|
||||
:pan-on-scroll="false"
|
||||
>
|
||||
<template #node-stage="{ data, id }">
|
||||
<div
|
||||
class="stage-node"
|
||||
:class="{
|
||||
running: data.isRunning,
|
||||
active: data.isActive,
|
||||
outline: data.outline,
|
||||
dimmed: data.opacity < 1,
|
||||
placeholder: data.status === 'placeholder',
|
||||
}"
|
||||
:style="{
|
||||
background: data.color,
|
||||
color: data.textColor,
|
||||
opacity: data.opacity,
|
||||
}"
|
||||
@click="onNodeClick(id)"
|
||||
>
|
||||
<span class="stage-label">{{ data.label }}</span>
|
||||
|
||||
<!-- Checkpoint indicator -->
|
||||
<span v-if="data.hasCheckpoint" class="checkpoint-badge" title="Checkpoint available">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
||||
<circle cx="5" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
<circle cx="5" cy="5" r="1.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<span class="stage-actions">
|
||||
<button
|
||||
v-if="data.hasStageEditor"
|
||||
class="stage-btn editor-btn"
|
||||
title="Stage editor"
|
||||
@click.stop="emit('open-region-editor', id)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="5" cy="5" r="3.5"/><line x1="7.5" y1="7.5" x2="11" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="stage-btn config-btn"
|
||||
title="Stage config"
|
||||
@click.stop="emit('open-stage-editor', id)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="6" cy="6" r="2"/><path d="M6 1v2M6 9v2M1 6h2M9 6h2M2.5 2.5l1.4 1.4M8.1 8.1l1.4 1.4M2.5 9.5l1.4-1.4M8.1 3.9l1.4-1.4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</VueFlow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph-renderer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.graph-renderer :deep(.vue-flow__background) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Hide default node styling — we use custom template */
|
||||
.graph-renderer :deep(.vue-flow__node-stage) {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.stage-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--panel-radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
min-width: 180px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stage-node.running {
|
||||
animation: node-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.stage-node.outline {
|
||||
box-shadow: 0 0 0 2px var(--status-processing);
|
||||
}
|
||||
|
||||
.stage-node.dimmed {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stage-node.placeholder {
|
||||
border: 1px dashed var(--text-secondary);
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stage-node.placeholder .stage-actions,
|
||||
.stage-node.placeholder .checkpoint-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stage-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkpoint-badge {
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.stage-node:hover .stage-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stage-btn {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.stage-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@keyframes node-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
</style>
|
||||
143
ui/framework/src/renderers/LogRenderer.vue
Normal file
143
ui/framework/src/renderers/LogRenderer.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
|
||||
export interface LogEntry {
|
||||
level: string
|
||||
stage: string
|
||||
msg: string
|
||||
ts: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
entries: LogEntry[]
|
||||
rowHeight?: number
|
||||
autoScroll?: boolean
|
||||
}>(), {
|
||||
rowHeight: 24,
|
||||
autoScroll: true,
|
||||
})
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const containerHeight = ref(0)
|
||||
const userScrolled = ref(false)
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
const start = Math.floor(scrollTop.value / props.rowHeight)
|
||||
const visible = Math.ceil(containerHeight.value / props.rowHeight) + 2
|
||||
return {
|
||||
start: Math.max(0, start - 1),
|
||||
end: Math.min(props.entries.length, start + visible),
|
||||
}
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => props.entries.length * props.rowHeight)
|
||||
|
||||
const visibleEntries = computed(() =>
|
||||
props.entries.slice(visibleRange.value.start, visibleRange.value.end).map((entry, i) => ({
|
||||
...entry,
|
||||
index: visibleRange.value.start + i,
|
||||
}))
|
||||
)
|
||||
|
||||
function onScroll(e: Event) {
|
||||
const el = e.target as HTMLElement
|
||||
scrollTop.value = el.scrollTop
|
||||
// If user scrolled away from bottom, pause auto-scroll
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < props.rowHeight * 2
|
||||
userScrolled.value = !atBottom
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (container.value && props.autoScroll && !userScrolled.value) {
|
||||
container.value.scrollTop = container.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.entries.length, () => {
|
||||
nextTick(scrollToBottom)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (container.value) {
|
||||
containerHeight.value = container.value.clientHeight
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
containerHeight.value = entry.contentRect.height
|
||||
})
|
||||
observer.observe(container.value)
|
||||
onUnmounted(() => observer.disconnect())
|
||||
}
|
||||
})
|
||||
|
||||
const levelClass = (level: string) => level.toLowerCase()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="log-renderer" ref="container" @scroll="onScroll">
|
||||
<div class="log-spacer" :style="{ height: totalHeight + 'px' }">
|
||||
<div
|
||||
class="log-viewport"
|
||||
:style="{ transform: `translateY(${visibleRange.start * rowHeight}px)` }"
|
||||
>
|
||||
<div
|
||||
v-for="entry in visibleEntries"
|
||||
:key="entry.index"
|
||||
class="log-row"
|
||||
:class="levelClass(entry.level)"
|
||||
:style="{ height: rowHeight + 'px' }"
|
||||
>
|
||||
<span class="log-ts">{{ entry.ts }}</span>
|
||||
<span class="log-level">{{ entry.level }}</span>
|
||||
<span class="log-stage">{{ entry.stage }}</span>
|
||||
<span class="log-msg">{{ entry.msg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="entries.length === 0" class="log-empty">
|
||||
Waiting for log events...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-renderer {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-spacer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.log-viewport {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-2);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.log-ts { color: var(--text-dim); min-width: 80px; flex-shrink: 0; }
|
||||
.log-level { min-width: 56px; font-weight: 600; flex-shrink: 0; }
|
||||
.log-stage { color: var(--status-processing); min-width: 120px; flex-shrink: 0; }
|
||||
.log-msg { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.log-row.info .log-level { color: var(--status-live); }
|
||||
.log-row.warning .log-level { color: var(--status-escalating); }
|
||||
.log-row.error .log-level { color: var(--status-error); }
|
||||
.log-row.debug .log-level { color: var(--text-dim); }
|
||||
|
||||
.log-empty {
|
||||
color: var(--text-dim);
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
122
ui/framework/src/renderers/TableRenderer.vue
Normal file
122
ui/framework/src/renderers/TableRenderer.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface TableColumn {
|
||||
key: string
|
||||
label: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
columns: TableColumn[]
|
||||
rows: Record<string, unknown>[]
|
||||
sortKey?: string
|
||||
sortDir?: 'asc' | 'desc'
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
sort: [key: string]
|
||||
}>()
|
||||
|
||||
const sorted = computed(() => {
|
||||
if (!props.sortKey) return props.rows
|
||||
const key = props.sortKey
|
||||
const dir = props.sortDir === 'desc' ? -1 : 1
|
||||
return [...props.rows].sort((a, b) => {
|
||||
const av = a[key] as number | string
|
||||
const bv = b[key] as number | string
|
||||
if (av < bv) return -1 * dir
|
||||
if (av > bv) return 1 * dir
|
||||
return 0
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-renderer">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:style="{ width: col.width }"
|
||||
@click="emits('sort', col.key)"
|
||||
class="sortable"
|
||||
>
|
||||
{{ col.label }}
|
||||
<span v-if="sortKey === col.key" class="sort-indicator">
|
||||
{{ sortDir === 'desc' ? '▼' : '▲' }}
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in sorted" :key="i">
|
||||
<td v-for="col in columns" :key="col.key">
|
||||
{{ row[col.key] }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td :colspan="columns.length" class="empty">No detections yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-renderer {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: var(--panel-border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
th:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
font-size: 9px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-bottom: 1px solid var(--surface-3);
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
</style>
|
||||
198
ui/framework/src/renderers/TimeSeriesRenderer.vue
Normal file
198
ui/framework/src/renderers/TimeSeriesRenderer.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import uPlot from 'uplot'
|
||||
import 'uplot/dist/uPlot.min.css'
|
||||
|
||||
export interface TimeSeriesSeries {
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Array of series configs (label + color) */
|
||||
series: TimeSeriesSeries[]
|
||||
/** Data: [timestamps[], series1[], series2[], ...] */
|
||||
data: uPlot.AlignedData
|
||||
/** Chart title (optional) */
|
||||
title?: string
|
||||
/** Stacked area mode */
|
||||
stacked?: boolean
|
||||
}>(), {
|
||||
stacked: false,
|
||||
})
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const zoomed = ref(false)
|
||||
let chart: uPlot | null = null
|
||||
|
||||
function buildOpts(): uPlot.Options {
|
||||
const seriesOpts: uPlot.Series[] = [
|
||||
{ label: 'Time' },
|
||||
...props.series.map((s) => ({
|
||||
label: s.label,
|
||||
stroke: s.color,
|
||||
fill: props.stacked ? s.color + '40' : undefined,
|
||||
width: 2,
|
||||
})),
|
||||
]
|
||||
|
||||
return {
|
||||
width: container.value?.clientWidth ?? 400,
|
||||
height: container.value?.clientHeight ?? 200,
|
||||
series: seriesOpts,
|
||||
axes: [
|
||||
{
|
||||
stroke: '#555568',
|
||||
grid: { stroke: '#2e2e3822' },
|
||||
size: 40,
|
||||
font: '10px monospace',
|
||||
ticks: { size: 3 },
|
||||
},
|
||||
{
|
||||
stroke: '#555568',
|
||||
grid: { stroke: '#2e2e3822' },
|
||||
size: 35,
|
||||
font: '10px monospace',
|
||||
ticks: { size: 3 },
|
||||
},
|
||||
],
|
||||
cursor: { show: true },
|
||||
legend: { show: true, live: false },
|
||||
padding: [8, 8, 0, 0],
|
||||
hooks: {
|
||||
setScale: [(_self: uPlot, scaleKey: string) => {
|
||||
if (scaleKey === 'x') zoomed.value = true
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
if (!chart) return
|
||||
const data = chart.data
|
||||
if (data && data[0] && data[0].length > 0) {
|
||||
const min = data[0][0]
|
||||
const max = data[0][data[0].length - 1]
|
||||
chart.setScale('x', { min, max })
|
||||
}
|
||||
zoomed.value = false
|
||||
}
|
||||
|
||||
function getLegendHeight(): number {
|
||||
if (!container.value) return 0
|
||||
const legend = container.value.querySelector('.u-legend') as HTMLElement | null
|
||||
return legend ? legend.offsetHeight : 0
|
||||
}
|
||||
|
||||
function createChart() {
|
||||
if (!container.value) return
|
||||
if (chart) chart.destroy()
|
||||
chart = new uPlot(buildOpts(), props.data, container.value)
|
||||
// Refit after legend renders
|
||||
nextTick(() => resize())
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!chart || !container.value) return
|
||||
const legendH = getLegendHeight()
|
||||
const availableH = container.value.clientHeight
|
||||
// uPlot height = canvas height (chart sets total = canvas + legend)
|
||||
const chartH = Math.max(60, availableH - legendH)
|
||||
chart.setSize({
|
||||
width: container.value.clientWidth,
|
||||
height: chartH,
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.data, (newData) => {
|
||||
if (chart) {
|
||||
chart.setData(newData)
|
||||
} else {
|
||||
nextTick(createChart)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(createChart)
|
||||
const observer = new ResizeObserver(resize)
|
||||
if (container.value) observer.observe(container.value)
|
||||
onUnmounted(() => {
|
||||
observer.disconnect()
|
||||
chart?.destroy()
|
||||
chart = null
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timeseries-wrapper">
|
||||
<button v-if="zoomed" class="reset-zoom" @click="resetZoom" title="Reset zoom">⟲</button>
|
||||
<div ref="container" class="timeseries-renderer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timeseries-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.reset-zoom {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 20;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--surface-3);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.reset-zoom:hover {
|
||||
opacity: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.timeseries-renderer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* uPlot creates a .u-wrap for canvas + a .u-legend below it */
|
||||
.timeseries-renderer :deep(.u-wrap) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.timeseries-renderer :deep(.u-legend) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeseries-renderer :deep(.u-legend) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 8px;
|
||||
}
|
||||
|
||||
.timeseries-renderer :deep(.u-legend .u-series) {
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
59
ui/framework/src/tokens.css
Normal file
59
ui/framework/src/tokens.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/* Framework design tokens — retheme by replacing this file */
|
||||
:root {
|
||||
/* spacing scale (4px base) */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
/* color — dark theme (observability UIs are always dark) */
|
||||
--surface-0: #0d0d0f;
|
||||
--surface-1: #16161a;
|
||||
--surface-2: #1e1e24;
|
||||
--surface-3: #26262f;
|
||||
--border: #2e2e38;
|
||||
|
||||
--text-primary: #e8e8f0;
|
||||
--text-secondary: #8888a0;
|
||||
--text-dim: #555568;
|
||||
|
||||
/* status colors */
|
||||
--status-idle: #555568;
|
||||
--status-live: #3ecf8e;
|
||||
--status-processing: #4f9cf9;
|
||||
--status-escalating: #f5a623;
|
||||
--status-error: #f06565;
|
||||
|
||||
/* confidence color scale (low → high) */
|
||||
--conf-low: #f06565;
|
||||
--conf-mid: #f5a623;
|
||||
--conf-high: #3ecf8e;
|
||||
|
||||
/* typography */
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--font-ui: 'Inter', system-ui, sans-serif;
|
||||
--font-size-sm: 11px;
|
||||
--font-size-base: 13px;
|
||||
--font-size-lg: 15px;
|
||||
|
||||
/* panel chrome */
|
||||
--panel-radius: 6px;
|
||||
--panel-border: 1px solid var(--border);
|
||||
--panel-header-height: 36px;
|
||||
}
|
||||
|
||||
/* Animated gradient outline for buttons in a waiting state.
|
||||
Usage: add class="waiting" to any button/element. */
|
||||
@keyframes waiting-glow {
|
||||
0% { box-shadow: 0 0 3px 1px var(--status-processing); }
|
||||
33% { box-shadow: 0 0 3px 1px var(--status-live); }
|
||||
66% { box-shadow: 0 0 3px 1px var(--status-escalating); }
|
||||
100% { box-shadow: 0 0 3px 1px var(--status-processing); }
|
||||
}
|
||||
|
||||
.waiting {
|
||||
animation: waiting-glow 2s linear infinite;
|
||||
outline: 1px solid transparent;
|
||||
}
|
||||
18
ui/framework/tsconfig.json
Normal file
18
ui/framework/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
7
ui/framework/vitest.config.ts
Normal file
7
ui/framework/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user