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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user