Initial commit: Nexus Mission Control Platform
- ASP.NET Core 10 Backend (JWT Auth, Agent config API) - Vue 3 Frontend (Dashboard, Team, Agents, Config Editor) - PostgreSQL Database - Docker Compose setup - Mission Control Dashboard redesign
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity } from '@lucide/vue'
|
||||
import { apiFetch } from '../services/api'
|
||||
import type { AgentDetail } from '../types'
|
||||
import ConfigTabs from '../components/config/ConfigTabs.vue'
|
||||
import ConfigEditor from '../components/config/ConfigEditor.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const agent = ref<AgentDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const configFiles = ref<ConfigFileInfo[]>([])
|
||||
const activeTab = ref(0)
|
||||
const configsLoading = ref(false)
|
||||
const configsError = ref('')
|
||||
|
||||
const initLoading = ref(true)
|
||||
|
||||
interface EditorState {
|
||||
content: string
|
||||
savedContent: string
|
||||
saving: boolean
|
||||
dirty: boolean
|
||||
saveStatus: 'idle' | 'saved' | 'error'
|
||||
saveMessage: string
|
||||
}
|
||||
|
||||
interface ConfigFileInfo {
|
||||
fileName: string
|
||||
size: number
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
interface ConfigFileDetail extends ConfigFileInfo {
|
||||
content: string
|
||||
}
|
||||
|
||||
const editorState = ref<EditorState>({
|
||||
content: '',
|
||||
savedContent: '',
|
||||
saving: false,
|
||||
dirty: false,
|
||||
saveStatus: 'idle',
|
||||
saveMessage: '',
|
||||
})
|
||||
|
||||
const agentId = route.params.id as string
|
||||
|
||||
const orderedTabs = ['IDENTITY.md', 'SOUL.md', 'AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md', 'USER.md']
|
||||
|
||||
const currentFile = computed(() => {
|
||||
if (!configFiles.value.length) return null
|
||||
const activeFile = configFiles.value[activeTab.value]
|
||||
return activeFile || configFiles.value[0]
|
||||
})
|
||||
|
||||
const activeTabFileName = computed(() => {
|
||||
return orderedTabs[activeTab.value] || null
|
||||
})
|
||||
|
||||
const fallbackName = computed(() => {
|
||||
return agentId.charAt(0).toUpperCase() + agentId.slice(1)
|
||||
})
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatModifiedAt(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const statusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'Online': return '#51d49a'
|
||||
case 'Degraded': return '#e5b05e'
|
||||
case 'Offline': return '#e16e75'
|
||||
default: return '#7e8799'
|
||||
}
|
||||
}
|
||||
|
||||
function formatLastSeen(dateStr?: string): string {
|
||||
if (!dateStr) return 'N/A'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
async function loadAgent() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/agents/${agentId}`)
|
||||
if (!response.ok) throw new Error(`Agent "${agentId}" not found`)
|
||||
agent.value = await response.json()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load agent data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfigFiles() {
|
||||
configsLoading.value = true
|
||||
configsError.value = ''
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/agents/${agentId}/config`)
|
||||
if (!response.ok) throw new Error('Failed to load config files')
|
||||
configFiles.value = await response.json()
|
||||
if (configFiles.value.length > 0) {
|
||||
const fileName = configFiles.value[0].fileName
|
||||
const tabIndex = orderedTabs.indexOf(fileName)
|
||||
activeTab.value = tabIndex >= 0 ? tabIndex : 0
|
||||
}
|
||||
if (configFiles.value.length > 0) {
|
||||
await loadFileContent(configFiles.value[0].fileName)
|
||||
}
|
||||
} catch (e) {
|
||||
configsError.value = e instanceof Error ? e.message : 'Failed to load config files'
|
||||
} finally {
|
||||
configsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFileContent(fileName: string) {
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`)
|
||||
if (!response.ok) throw new Error(`Failed to load ${fileName}`)
|
||||
const data: ConfigFileDetail = await response.json()
|
||||
editorState.value = {
|
||||
content: data.content,
|
||||
savedContent: data.content,
|
||||
saving: false,
|
||||
dirty: false,
|
||||
saveStatus: 'idle',
|
||||
saveMessage: '',
|
||||
}
|
||||
} catch (e) {
|
||||
editorState.value = {
|
||||
content: '',
|
||||
savedContent: '',
|
||||
saving: false,
|
||||
dirty: false,
|
||||
saveStatus: 'error',
|
||||
saveMessage: e instanceof Error ? e.message : `Failed to load ${fileName}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function switchTab(index: number) {
|
||||
if (activeTab.value === index) return
|
||||
activeTab.value = index
|
||||
const fileName = orderedTabs[index]
|
||||
if (!fileName) return
|
||||
await loadFileContent(fileName)
|
||||
}
|
||||
|
||||
function onContentChange(value: string) {
|
||||
editorState.value.content = value
|
||||
editorState.value.dirty = value !== editorState.value.savedContent
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
const fileName = activeTabFileName.value
|
||||
if (!fileName || !editorState.value.dirty) return
|
||||
|
||||
editorState.value.saving = true
|
||||
editorState.value.saveStatus = 'idle'
|
||||
editorState.value.saveMessage = ''
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: editorState.value.content }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error((err as any).error || 'Failed to save file')
|
||||
}
|
||||
|
||||
const result: { fileName: string; size: number; modifiedAt: string } = await response.json()
|
||||
editorState.value.savedContent = editorState.value.content
|
||||
editorState.value.dirty = false
|
||||
editorState.value.saveStatus = 'saved'
|
||||
editorState.value.saveMessage = 'Gespeichert'
|
||||
|
||||
const idx = configFiles.value.findIndex(f => f.fileName === fileName)
|
||||
if (idx >= 0) {
|
||||
configFiles.value[idx] = { ...configFiles.value[idx], size: result.size, modifiedAt: result.modifiedAt }
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (editorState.value.saveStatus === 'saved') {
|
||||
editorState.value.saveStatus = 'idle'
|
||||
editorState.value.saveMessage = ''
|
||||
}
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
editorState.value.saveStatus = 'error'
|
||||
editorState.value.saveMessage = e instanceof Error ? e.message : 'Failed to save file'
|
||||
} finally {
|
||||
editorState.value.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initLoading.value = true
|
||||
await Promise.allSettled([
|
||||
loadAgent(),
|
||||
loadConfigFiles(),
|
||||
])
|
||||
initLoading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<button class="back-link" @click="router.push('/team')">
|
||||
<ArrowLeft :size="14" />
|
||||
Zurück zum Team
|
||||
</button>
|
||||
|
||||
<div v-if="initLoading" class="status-message">
|
||||
<Loader2 :size="20" class="spin" />
|
||||
Loading agent data...
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Agent header -->
|
||||
<div class="agent-header">
|
||||
<div class="agent-avatar" :class="agentId">
|
||||
<Bot :size="24" />
|
||||
</div>
|
||||
<div class="agent-header-info">
|
||||
<span class="eyebrow">{{ agent?.role?.toUpperCase() || 'AGENT' }}</span>
|
||||
<h1>{{ agent?.name || fallbackName }}</h1>
|
||||
<div v-if="agent" class="agent-status-row">
|
||||
<span :style="{ background: statusColor(agent.status) }" class="status-dot"></span>
|
||||
<span class="status-label">{{ agent.status }}</span>
|
||||
<span class="status-sep">·</span>
|
||||
<span class="status-label mono">{{ agent.model || 'N/A' }}</span>
|
||||
<span v-if="agent.lastSeen" class="status-sep">·</span>
|
||||
<span v-if="agent.lastSeen" class="status-label">
|
||||
<Activity :size="11" />
|
||||
{{ formatLastSeen(agent.lastSeen) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="error && !agent" class="agent-status-row">
|
||||
<span class="status-label muted">
|
||||
<AlertCircle :size="11" />
|
||||
{{ error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config section -->
|
||||
<div class="config-section">
|
||||
<div v-if="configsLoading" class="status-message">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading config files...
|
||||
</div>
|
||||
|
||||
<div v-else-if="configsError" class="status-message error">
|
||||
<AlertCircle :size="16" />
|
||||
{{ configsError }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<ConfigTabs
|
||||
:tabs="orderedTabs"
|
||||
:active-tab="activeTab"
|
||||
@switch-tab="switchTab"
|
||||
/>
|
||||
|
||||
<ConfigEditor
|
||||
:file-name="activeTabFileName"
|
||||
:file-size="currentFile ? formatFileSize(currentFile.size) : ''"
|
||||
:file-modified="currentFile ? formatModifiedAt(currentFile.modifiedAt) : ''"
|
||||
:content="editorState.content"
|
||||
:dirty="editorState.dirty"
|
||||
:saving="editorState.saving"
|
||||
:save-status="editorState.saveStatus"
|
||||
:save-message="editorState.saveMessage"
|
||||
@update-content="onContentChange"
|
||||
@save="saveFile"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: var(--panel);
|
||||
color: #7e8799;
|
||||
font-size: 10.5px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 20px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.back-link:hover {
|
||||
border-color: #443d7c;
|
||||
color: #d8dbe3;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px;
|
||||
color: #7e8799;
|
||||
font-size: 12px;
|
||||
}
|
||||
.status-message.error {
|
||||
color: #e16e75;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.agent-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.agent-avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(139,124,246,.1);
|
||||
color: #8b7cf6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agent-avatar.iris { background: rgba(139,124,246,.15); color: #8b7cf6; }
|
||||
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: #4d8cf6; }
|
||||
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: #4da8f6; }
|
||||
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: #f6a84d; }
|
||||
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: #8b4df6; }
|
||||
.agent-avatar.executor { background: rgba(77,246,212,.15); color: #4df6d4; }
|
||||
|
||||
.agent-header-info {
|
||||
flex: 1;
|
||||
}
|
||||
.agent-header-info .eyebrow {
|
||||
display: block;
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
color: var(--accent, #7b6ef2);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.agent-header-info h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.agent-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-label {
|
||||
font-size: 11px;
|
||||
color: #7e8799;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.status-label.muted { color: #6b7385; }
|
||||
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
|
||||
.status-sep { color: #3d4152; font-size: 11px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail-page {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user