feat: add supervisor prototype with embedded frontend
This commit is contained in:
23
web/index.html
Normal file
23
web/index.html
Normal file
@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Supervisor</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1591
web/package-lock.json
generated
Normal file
1591
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
web/package.json
Normal file
24
web/package.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "supervisor-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.8.4",
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
7
web/src/App.vue
Normal file
7
web/src/App.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<AppShell />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppShell from './components/layout/AppShell.vue'
|
||||
</script>
|
||||
6
web/src/api/http.ts
Normal file
6
web/src/api/http.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000,
|
||||
})
|
||||
47
web/src/api/sessions.ts
Normal file
47
web/src/api/sessions.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { http } from './http'
|
||||
|
||||
export interface SessionDto {
|
||||
id: string
|
||||
name: string
|
||||
agentId: string
|
||||
command: string
|
||||
status: string
|
||||
createdAt: string
|
||||
startedAt?: string
|
||||
exitedAt?: string
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
name: string
|
||||
agentId: string
|
||||
command: string
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<SessionDto[]> {
|
||||
const { data } = await http.get<SessionDto[]>('/sessions')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createSession(payload: CreateSessionRequest): Promise<SessionDto> {
|
||||
const { data } = await http.post<SessionDto>('/sessions', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSession(id: string): Promise<SessionDto> {
|
||||
const { data } = await http.get<SessionDto>(`/sessions/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function postSessionInput(id: string, input: string): Promise<void> {
|
||||
await http.post(`/sessions/${id}/input`, { input })
|
||||
}
|
||||
|
||||
export async function stopSession(id: string): Promise<SessionDto> {
|
||||
const { data } = await http.post<SessionDto>(`/sessions/${id}/stop`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<void> {
|
||||
await http.delete(`/sessions/${id}`)
|
||||
}
|
||||
59
web/src/api/ws.ts
Normal file
59
web/src/api/ws.ts
Normal file
@ -0,0 +1,59 @@
|
||||
export interface WSEnvelope<T = unknown> {
|
||||
type: string
|
||||
session: string
|
||||
payload: T
|
||||
}
|
||||
|
||||
export class TerminalSocket {
|
||||
private ws: WebSocket | null = null
|
||||
|
||||
constructor(
|
||||
private readonly sessionId: string,
|
||||
private readonly onMessage: (msg: WSEnvelope) => void,
|
||||
private readonly onError?: (error: Event) => void,
|
||||
private readonly onClose?: () => void,
|
||||
) {}
|
||||
|
||||
connect(): void {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const url = `${protocol}://${window.location.host}/ws/sessions/${this.sessionId}`
|
||||
this.ws = new WebSocket(url)
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
this.onMessage(JSON.parse(event.data) as WSEnvelope)
|
||||
} catch {
|
||||
// ignore malformed payloads
|
||||
}
|
||||
}
|
||||
this.ws.onerror = (event) => this.onError?.(event)
|
||||
this.ws.onclose = () => this.onClose?.()
|
||||
}
|
||||
|
||||
sendInput(data: string): void {
|
||||
this.send({
|
||||
type: 'terminal.input',
|
||||
session: this.sessionId,
|
||||
payload: { data },
|
||||
})
|
||||
}
|
||||
|
||||
sendResize(cols: number, rows: number): void {
|
||||
this.send({
|
||||
type: 'terminal.resize',
|
||||
session: this.sessionId,
|
||||
payload: { cols, rows },
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.ws?.close()
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
private send(payload: WSEnvelope): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
this.ws.send(JSON.stringify(payload))
|
||||
}
|
||||
}
|
||||
54
web/src/components/layout/AppShell.vue
Normal file
54
web/src/components/layout/AppShell.vue
Normal 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>
|
||||
33
web/src/components/layout/Sidebar.vue
Normal file
33
web/src/components/layout/Sidebar.vue
Normal 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>
|
||||
33
web/src/components/layout/Topbar.vue
Normal file
33
web/src/components/layout/Topbar.vue
Normal 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>
|
||||
71
web/src/components/sessions/SessionCard.vue
Normal file
71
web/src/components/sessions/SessionCard.vue
Normal 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>
|
||||
57
web/src/components/sessions/SessionList.vue
Normal file
57
web/src/components/sessions/SessionList.vue
Normal 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>
|
||||
51
web/src/components/terminals/TerminalTabs.vue
Normal file
51
web/src/components/terminals/TerminalTabs.vue
Normal 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>
|
||||
131
web/src/components/terminals/XTermView.vue
Normal file
131
web/src/components/terminals/XTermView.vue
Normal 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>
|
||||
10
web/src/main.ts
Normal file
10
web/src/main.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
22
web/src/router/index.ts
Normal file
22
web/src/router/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import DashboardView from '../views/DashboardView.vue'
|
||||
import SessionView from '../views/SessionView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: DashboardView,
|
||||
},
|
||||
{
|
||||
path: '/sessions/:id',
|
||||
name: 'session',
|
||||
component: SessionView,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
91
web/src/stores/sessions.ts
Normal file
91
web/src/stores/sessions.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
getSession,
|
||||
listSessions,
|
||||
postSessionInput,
|
||||
stopSession,
|
||||
type CreateSessionRequest,
|
||||
type SessionDto,
|
||||
} from '../api/sessions'
|
||||
|
||||
interface SessionsState {
|
||||
sessions: SessionDto[]
|
||||
activeSessionId: string | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useSessionsStore = defineStore('sessions', {
|
||||
state: (): SessionsState => ({
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
getters: {
|
||||
activeSession(state): SessionDto | undefined {
|
||||
return state.sessions.find((item) => item.id === state.activeSessionId)
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async fetchSessions() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
this.sessions = await listSessions()
|
||||
} catch (err) {
|
||||
this.error = String(err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async createSession(payload: CreateSessionRequest) {
|
||||
const created = await createSession(payload)
|
||||
this.sessions = [created, ...this.sessions.filter((item) => item.id !== created.id)]
|
||||
this.activeSessionId = created.id
|
||||
return created
|
||||
},
|
||||
async loadSession(id: string) {
|
||||
const current = await getSession(id)
|
||||
const idx = this.sessions.findIndex((item) => item.id === id)
|
||||
if (idx >= 0) {
|
||||
this.sessions.splice(idx, 1, current)
|
||||
} else {
|
||||
this.sessions.unshift(current)
|
||||
}
|
||||
this.activeSessionId = id
|
||||
return current
|
||||
},
|
||||
setActiveSession(id: string) {
|
||||
this.activeSessionId = id
|
||||
},
|
||||
async stopSession(id: string) {
|
||||
const updated = await stopSession(id)
|
||||
const idx = this.sessions.findIndex((item) => item.id === id)
|
||||
if (idx >= 0) {
|
||||
this.sessions.splice(idx, 1, updated)
|
||||
}
|
||||
return updated
|
||||
},
|
||||
async sendInput(id: string, input: string) {
|
||||
await postSessionInput(id, input)
|
||||
},
|
||||
async removeSession(id: string) {
|
||||
await deleteSession(id)
|
||||
this.sessions = this.sessions.filter((item) => item.id !== id)
|
||||
if (this.activeSessionId === id) {
|
||||
this.activeSessionId = null
|
||||
}
|
||||
},
|
||||
upsertSession(session: SessionDto) {
|
||||
const idx = this.sessions.findIndex((item) => item.id === session.id)
|
||||
if (idx >= 0) {
|
||||
this.sessions.splice(idx, 1, { ...this.sessions[idx], ...session })
|
||||
} else {
|
||||
this.sessions.unshift(session)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
76
web/src/views/DashboardView.vue
Normal file
76
web/src/views/DashboardView.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<section class="dashboard">
|
||||
<h2>Dashboard</h2>
|
||||
|
||||
<form class="create" @submit.prevent="onCreate">
|
||||
<input v-model="form.name" type="text" placeholder="Session name" />
|
||||
<input v-model="form.agentId" type="text" placeholder="Agent ID (optional)" />
|
||||
<input v-model="form.command" type="text" placeholder="Command, e.g. bash" />
|
||||
<button type="submit">Create Session</button>
|
||||
</form>
|
||||
|
||||
<div class="stats">
|
||||
<p>Total sessions: {{ store.sessions.length }}</p>
|
||||
<p>Running: {{ runningCount }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSessionsStore } from '../stores/sessions'
|
||||
|
||||
const store = useSessionsStore()
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
agentId: '',
|
||||
command: 'bash',
|
||||
})
|
||||
|
||||
const runningCount = computed(() => {
|
||||
return store.sessions.filter((item) => item.status === 'running').length
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchSessions()
|
||||
})
|
||||
|
||||
async function onCreate(): Promise<void> {
|
||||
const session = await store.createSession({
|
||||
name: form.name,
|
||||
agentId: form.agentId,
|
||||
command: form.command,
|
||||
})
|
||||
router.push(`/sessions/${session.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.create {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.create input,
|
||||
.create button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2f4358;
|
||||
background: #121e2a;
|
||||
color: #d7e3ec;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
color: #99adbf;
|
||||
}
|
||||
</style>
|
||||
90
web/src/views/SessionView.vue
Normal file
90
web/src/views/SessionView.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<section class="view">
|
||||
<TerminalTabs
|
||||
v-if="store.sessions.length > 0"
|
||||
:sessions="store.sessions"
|
||||
:active-id="sessionId"
|
||||
@select="onSelect"
|
||||
/>
|
||||
|
||||
<div class="toolbar" v-if="session">
|
||||
<strong>{{ session.name }}</strong>
|
||||
<span>{{ session.command }}</span>
|
||||
<button @click="onStop">Stop</button>
|
||||
</div>
|
||||
|
||||
<div class="terminal-host" v-if="sessionId">
|
||||
<XTermView :session-id="sessionId" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import TerminalTabs from '../components/terminals/TerminalTabs.vue'
|
||||
import XTermView from '../components/terminals/XTermView.vue'
|
||||
import { useSessionsStore } from '../stores/sessions'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
|
||||
const store = useSessionsStore()
|
||||
const router = useRouter()
|
||||
|
||||
const sessionId = computed(() => props.id)
|
||||
const session = computed(() => store.sessions.find((item) => item.id === sessionId.value))
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchSessions()
|
||||
await store.loadSession(sessionId.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.id,
|
||||
async (id) => {
|
||||
await store.loadSession(id)
|
||||
},
|
||||
)
|
||||
|
||||
function onSelect(id: string): void {
|
||||
store.setActiveSession(id)
|
||||
router.push(`/sessions/${id}`)
|
||||
}
|
||||
|
||||
async function onStop(): Promise<void> {
|
||||
await store.stopSession(sessionId.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid #223143;
|
||||
color: #c8d7e3;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
margin-left: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #4e677f;
|
||||
background: #1d2b3a;
|
||||
color: #e5eef5;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.terminal-host {
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
16
web/tsconfig.json
Normal file
16
web/tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
22
web/vite.config.ts
Normal file
22
web/vite.config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user