feat: add supervisor prototype with embedded frontend

This commit is contained in:
root
2026-03-09 19:15:53 +01:00
parent 96c4ce1697
commit 84de557052
56 changed files with 4044 additions and 10 deletions

View File

@ -0,0 +1,54 @@
<template>
<div class="shell">
<Sidebar class="sidebar" />
<section class="main">
<Topbar />
<div class="content">
<RouterView />
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import Sidebar from './Sidebar.vue'
import Topbar from './Topbar.vue'
</script>
<style scoped>
.shell {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
background: #0b1117;
color: #d8e3ed;
}
.sidebar {
border-right: 1px solid #223143;
}
.main {
display: grid;
grid-template-rows: 64px 1fr;
min-width: 0;
}
.content {
min-width: 0;
min-height: 0;
}
@media (max-width: 900px) {
.shell {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar {
border-right: none;
border-bottom: 1px solid #223143;
}
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<aside class="sidebar-wrap">
<div class="header">
<h1>Supervisor</h1>
<small>sessions</small>
</div>
<SessionList />
</aside>
</template>
<script setup lang="ts">
import SessionList from '../sessions/SessionList.vue'
</script>
<style scoped>
.sidebar-wrap {
padding: 16px;
display: grid;
grid-template-rows: auto 1fr;
gap: 12px;
background: linear-gradient(180deg, #101922 0%, #0d141c 100%);
}
.header h1 {
margin: 0;
font-size: 20px;
letter-spacing: 0.04em;
}
.header small {
color: #7b95b0;
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<header class="topbar">
<div class="title">Runtime Console</div>
<RouterLink class="btn" to="/">Dashboard</RouterLink>
</header>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<style scoped>
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #101922;
border-bottom: 1px solid #223143;
}
.title {
font-weight: 600;
}
.btn {
color: #d8e3ed;
text-decoration: none;
border: 1px solid #39506a;
border-radius: 8px;
padding: 6px 10px;
}
</style>

View File

@ -0,0 +1,71 @@
<template>
<article class="card" :class="{ active }" @click="$emit('select', session.id)">
<div class="top">
<strong>{{ session.name || session.id }}</strong>
<button v-if="canRemove" class="remove" @click.stop="$emit('remove', session.id)">Remove</button>
</div>
<p>{{ session.command }}</p>
<small>{{ session.status }}</small>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { SessionDto } from '../../api/sessions'
const props = defineProps<{
session: SessionDto
active: boolean
}>()
defineEmits<{
(e: 'select', id: string): void
(e: 'remove', id: string): void
}>()
const removableStatuses = new Set(['stopped', 'exited', 'error'])
const canRemove = computed(() => removableStatuses.has(props.session.status))
</script>
<style scoped>
.card {
border: 1px solid #253549;
border-radius: 10px;
padding: 10px;
background: #111a24;
cursor: pointer;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card.active {
border-color: #63a0ff;
background: #1a2532;
}
.remove {
border: 1px solid #4f637a;
background: #203040;
color: #e5eef5;
border-radius: 8px;
padding: 4px 8px;
font-size: 12px;
}
.card p {
margin: 8px 0;
color: #9bb2c8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card small {
color: #73d0a9;
}
</style>

View File

@ -0,0 +1,57 @@
<template>
<div class="list">
<button class="refresh" @click="store.fetchSessions">Refresh</button>
<SessionCard
v-for="item in store.sessions"
:key="item.id"
:session="item"
:active="item.id === store.activeSessionId"
@select="openSession"
@remove="removeSession"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionsStore } from '../../stores/sessions'
import SessionCard from './SessionCard.vue'
const store = useSessionsStore()
const router = useRouter()
const route = useRoute()
onMounted(() => {
store.fetchSessions()
})
function openSession(id: string): void {
store.setActiveSession(id)
router.push(`/sessions/${id}`)
}
async function removeSession(id: string): Promise<void> {
await store.removeSession(id)
if (route.params.id === id) {
router.push('/')
}
}
</script>
<style scoped>
.list {
display: grid;
gap: 8px;
align-content: start;
overflow: auto;
}
.refresh {
border: 1px solid #2f4054;
background: #162331;
color: #c6d4e1;
border-radius: 8px;
padding: 8px;
}
</style>

View File

@ -0,0 +1,51 @@
<template>
<div class="tabs">
<button
v-for="item in sessions"
:key="item.id"
class="tab"
:class="{ active: item.id === activeId }"
@click="$emit('select', item.id)"
>
{{ item.name || item.id }}
</button>
</div>
</template>
<script setup lang="ts">
import type { SessionDto } from '../../api/sessions'
defineProps<{
sessions: SessionDto[]
activeId: string
}>()
defineEmits<{
(e: 'select', id: string): void
}>()
</script>
<style scoped>
.tabs {
display: flex;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid #223143;
background: #0f1922;
overflow-x: auto;
}
.tab {
border: 1px solid #2f4155;
background: #152230;
color: #c9d6e1;
border-radius: 8px;
padding: 6px 10px;
white-space: nowrap;
}
.tab.active {
border-color: #6ca7ff;
background: #213245;
}
</style>

View File

@ -0,0 +1,131 @@
<template>
<div ref="container" class="terminal" />
</template>
<script setup lang="ts">
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from '@xterm/xterm'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { TerminalSocket, type WSEnvelope } from '../../api/ws'
const props = defineProps<{
sessionId: string
}>()
const container = ref<HTMLDivElement | null>(null)
let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null
let socket: TerminalSocket | null = null
let resizeObserver: ResizeObserver | null = null
function initTerminal(): void {
if (!container.value || terminal) {
return
}
terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
scrollback: 5000,
convertEol: true,
theme: {
background: '#05090e',
foreground: '#d6dfeb',
},
})
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(container.value)
fitAddon.fit()
terminal.onData((data: string) => {
socket?.sendInput(data)
})
}
function connectSocket(sessionId: string): void {
socket?.close()
terminal?.reset()
socket = new TerminalSocket(
sessionId,
(message: WSEnvelope) => {
if (!terminal) {
return
}
if (message.type === 'terminal.output') {
const payload = message.payload as { data?: string }
if (payload.data) {
terminal.write(payload.data)
}
}
if (message.type === 'session.status') {
const payload = message.payload as { status?: string }
if (payload.status) {
terminal.writeln(`\r\n[status] ${payload.status}`)
}
}
if (message.type === 'error') {
const payload = message.payload as { message?: string }
if (payload.message) {
terminal.writeln(`\r\n[error] ${payload.message}`)
}
}
},
() => {
terminal?.writeln('\r\n[ws] websocket error')
},
)
socket.connect()
window.setTimeout(() => sendResize(), 100)
}
function sendResize(): void {
if (!terminal || !fitAddon) {
return
}
fitAddon.fit()
socket?.sendResize(terminal.cols, terminal.rows)
}
onMounted(() => {
initTerminal()
connectSocket(props.sessionId)
if (container.value) {
resizeObserver = new ResizeObserver(() => {
sendResize()
})
resizeObserver.observe(container.value)
}
})
watch(
() => props.sessionId,
(sessionId) => {
if (!terminal) {
return
}
connectSocket(sessionId)
},
)
onBeforeUnmount(() => {
resizeObserver?.disconnect()
socket?.close()
terminal?.dispose()
})
</script>
<style scoped>
.terminal {
width: 100%;
height: 100%;
min-height: 280px;
}
</style>