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:
Bao
2026-06-09 16:31:42 +02:00
commit eeb6174de0
248 changed files with 19706 additions and 0 deletions
+424
View File
@@ -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>
+316
View File
@@ -0,0 +1,316 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { Bot, Code2, Server, Shield, Search, Terminal, Users } from '@lucide/vue'
const router = useRouter()
interface AgentCard {
id: string
name: string
role: string
description: string
tags: string[]
color: string
icon: string
}
const agents: AgentCard[] = [
{
id: 'iris',
name: 'Iris',
role: 'Chief of Staff',
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
tags: ['Orchestration', 'Delegation', 'Approval'],
color: '#8b7cf6',
icon: 'bot',
},
{
id: 'programmer',
name: 'Programmer',
role: 'Lead Developer',
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
tags: ['Coding', 'Development', 'Builds'],
color: '#4d8cf6',
icon: 'code',
},
{
id: 'architekt',
name: 'Architekt',
role: 'Infrastructure Engineer',
description: 'Verantwortlich für Docker, Nginx, Deployment und VPS-Infrastruktur.',
tags: ['Infrastructure', 'Deployment', 'Docker'],
color: '#4da8f6',
icon: 'server',
},
{
id: 'reviewer',
name: 'Reviewer',
role: 'Code QA',
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
tags: ['QA', 'Security', 'Code Review'],
color: '#f6a84d',
icon: 'shield',
},
{
id: 'researcher',
name: 'Researcher',
role: 'Research Analyst',
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
tags: ['Research', 'Analysis', 'Fact-Checking'],
color: '#8b4df6',
icon: 'search',
},
{
id: 'executor',
name: 'Executor',
role: 'Host Executor',
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
tags: ['Execution', 'Docker', 'VPS'],
color: '#4df6d4',
icon: 'terminal',
},
]
function goToAgent(id: string) {
router.push(`/agents/${id}`)
}
function resolveIcon(iconName: string) {
switch (iconName) {
case 'bot': return Bot
case 'code': return Code2
case 'server': return Server
case 'shield': return Shield
case 'search': return Search
case 'terminal': return Terminal
default: return Bot
}
}
</script>
<template>
<div class="agents-page">
<!-- Header -->
<div class="page-header">
<div class="header-icon-wrap">
<Users :size="22" />
</div>
<div class="header-text">
<h1>Agents</h1>
<p class="header-subtitle">{{ agents.length }} AI agents each with a real role and a real personality.</p>
</div>
</div>
<!-- Agent grid -->
<div class="agents-grid">
<article
v-for="agent in agents"
:key="agent.id"
class="agent-card"
:style="{ '--card-color': agent.color }"
@click="goToAgent(agent.id)"
>
<div class="card-stripe" :style="{ background: agent.color }"></div>
<div class="card-content">
<div class="card-header">
<div class="card-icon-wrap" :style="{ background: `${agent.color}18`, color: agent.color }">
<component :is="resolveIcon(agent.icon)" :size="18" />
</div>
<div class="card-info">
<h3 class="card-name">{{ agent.name }}</h3>
<span class="card-role">{{ agent.role }}</span>
</div>
</div>
<p class="card-desc">{{ agent.description }}</p>
<div class="card-tags">
<span
v-for="tag in agent.tags"
:key="tag"
class="card-tag"
:style="{ background: `${agent.color}14`, color: agent.color, borderColor: `${agent.color}24` }"
>
{{ tag }}
</span>
</div>
</div>
<div class="card-footer">
<span class="footer-label">View Profile</span>
<span class="footer-arrow">&rarr;</span>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.agents-page {
max-width: 960px;
margin: 0 auto;
padding-bottom: 40px;
}
/* Page header */
.page-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 28px;
}
.header-icon-wrap {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 11px;
background: rgba(139, 124, 246, 0.1);
color: #8b7cf6;
flex-shrink: 0;
}
.header-text h1 {
margin: 0 0 2px;
font-size: 22px;
font-weight: 600;
color: #e8eaf0;
}
.header-subtitle {
margin: 0;
font-size: 11px;
color: #7e8799;
}
/* Agent grid */
.agents-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
}
/* Agent card */
.agent-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 11px;
cursor: pointer;
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
}
.agent-card:hover {
border-color: var(--card-color);
box-shadow: 0 0 20px color-mix(in srgb, var(--card-color) 10%, transparent);
transform: translateY(-2px);
}
.card-stripe {
height: 3px;
flex-shrink: 0;
}
.card-content {
padding: 16px 16px 12px;
flex: 1;
display: flex;
flex-direction: column;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.card-icon-wrap {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 9px;
flex-shrink: 0;
}
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
margin: 0 0 1px;
font-size: 13px;
font-weight: 600;
color: #e8eaf0;
}
.card-role {
display: block;
font-size: 9px;
color: #7e8799;
letter-spacing: 0.02em;
}
.card-desc {
font-size: 10.5px;
color: #7e8799;
line-height: 1.5;
margin: 0 0 10px;
flex: 1;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.card-tag {
display: inline-block;
font-size: 8.5px;
font-weight: 600;
padding: 2px 7px;
border-radius: 4px;
border: 1px solid transparent;
letter-spacing: 0.02em;
}
.card-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
padding: 8px 16px;
border-top: 1px solid var(--line);
font-size: 9px;
font-weight: 600;
color: #6b7385;
text-transform: uppercase;
letter-spacing: 0.06em;
transition: color 0.15s;
}
.agent-card:hover .card-footer {
color: var(--card-color);
}
.footer-arrow {
font-size: 12px;
line-height: 1;
}
/* Responsive */
@media (max-width: 820px) {
.agents-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 540px) {
.agents-grid {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
</style>
+397
View File
@@ -0,0 +1,397 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { Calendar, Clock, RefreshCw, Loader2, AlertCircle, CheckCircle2, Timer } from '@lucide/vue'
import { apiFetch } from '../services/api'
interface CalendarJob {
id: string
name: string
schedule: string
lastRun: string | null
nextRun: string | null
status: string
}
interface UpcomingJob {
id: string
name: string
nextRun: string
schedule: string
}
const jobs = ref<CalendarJob[]>([])
const upcoming = ref<UpcomingJob[]>([])
const loading = ref(false)
const upcomingLoading = ref(false)
const error = ref('')
const upcomingError = ref('')
function formatSchedule(cron: string): string {
const map: Record<string, string> = {
'*/5 * * * *': 'Every 5 minutes',
'*/10 * * * *': 'Every 10 minutes',
'*/15 * * * *': 'Every 15 minutes',
'*/30 * * * *': 'Every 30 minutes',
'0 * * * *': 'Every hour',
'0 */6 * * *': 'Every 6 hours',
'0 */12 * * *': 'Every 12 hours',
'0 0 * * *': 'Daily at midnight',
'0 3 * * *': 'Daily at 03:00',
'0 4 * * *': 'Daily at 04:00',
'0 2 * * *': 'Daily at 02:00',
}
return map[cron] ?? cron
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const diffMs = d.getTime() - now.getTime()
const diffMin = Math.round(diffMs / 60000)
const dateFormatted = d.toLocaleDateString('de-DE', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
if (diffMin < 0) {
return `${dateFormatted} (${Math.abs(diffMin)}m ago)`
}
if (diffMin < 60) {
return `${dateFormatted} (in ${diffMin}m)`
}
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) {
return `${dateFormatted} (in ${diffHr}h)`
}
return dateFormatted
}
function statusIcon(status: string) {
if (status === 'completed') return CheckCircle2
if (status === 'running') return Timer
if (status === 'failed') return AlertCircle
return Clock
}
function statusClass(status: string): string {
if (status === 'completed') return 'status-completed'
if (status === 'running') return 'status-running'
if (status === 'failed') return 'status-failed'
return 'status-idle'
}
async function loadJobs() {
loading.value = true
error.value = ''
try {
const response = await apiFetch('/api/v1/calendar')
if (!response.ok) throw new Error('Failed to load jobs')
jobs.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load calendar'
} finally {
loading.value = false
}
}
async function loadUpcoming() {
upcomingLoading.value = true
upcomingError.value = ''
try {
const response = await apiFetch('/api/v1/calendar/upcoming')
if (!response.ok) throw new Error('Failed to load upcoming jobs')
upcoming.value = await response.json()
} catch (e) {
upcomingError.value = e instanceof Error ? e.message : 'Failed to load upcoming'
} finally {
upcomingLoading.value = false
}
}
const sortedUpcoming = computed(() => {
return [...upcoming.value].sort((a, b) => {
return new Date(a.nextRun).getTime() - new Date(b.nextRun).getTime()
})
})
onMounted(() => {
loadJobs()
loadUpcoming()
})
</script>
<template>
<div class="page-heading">
<div>
<span class="eyebrow">SCHEDULER</span>
<h1>Calendar</h1>
<p>Scheduled jobs and cron tasks across the OpenClaw gateway.</p>
</div>
<button class="refresh" @click="loadJobs(); loadUpcoming()">
<RefreshCw :size="15" :class="{ spin: loading || upcomingLoading }" />
Refresh
</button>
</div>
<div class="calendar-layout">
<!-- Upcoming section -->
<section class="calendar-panel upcoming-panel">
<header class="calendar-panel-head">
<Calendar :size="16" />
<h2>Upcoming Executions</h2>
</header>
<template v-if="upcomingLoading">
<div class="calendar-status">
<Loader2 :size="16" class="spin" />
Loading upcoming jobs...
</div>
</template>
<template v-else-if="upcomingError">
<div class="calendar-status error">{{ upcomingError }}</div>
</template>
<template v-else-if="sortedUpcoming.length">
<div
v-for="job in sortedUpcoming.slice(0, 5)"
:key="job.id"
class="upcoming-item"
>
<div class="upcoming-item-icon">
<Clock :size="14" />
</div>
<div class="upcoming-item-info">
<strong>{{ job.name }}</strong>
<span class="upcoming-item-schedule">{{ formatSchedule(job.schedule) }}</span>
<span class="upcoming-item-next">{{ formatDate(job.nextRun) }}</span>
</div>
</div>
</template>
<div v-else class="calendar-status">No upcoming jobs</div>
</section>
<!-- All jobs list -->
<section class="calendar-panel all-jobs-panel">
<header class="calendar-panel-head">
<Timer :size="16" />
<h2>All Scheduled Jobs</h2>
</header>
<template v-if="loading">
<div class="calendar-status">
<Loader2 :size="16" class="spin" />
Loading jobs...
</div>
</template>
<template v-else-if="error">
<div class="calendar-status error">{{ error }}</div>
</template>
<template v-else-if="jobs.length">
<div
v-for="job in jobs"
:key="job.id"
class="job-item"
>
<div class="job-item-icon" :class="statusClass(job.status)">
<component :is="statusIcon(job.status)" :size="14" />
</div>
<div class="job-item-info">
<strong>{{ job.name }}</strong>
<span class="job-item-id">{{ job.id }}</span>
<span class="job-item-schedule">{{ formatSchedule(job.schedule) }}</span>
</div>
<div class="job-item-meta">
<span :class="['job-status-badge', statusClass(job.status)]">{{ job.status }}</span>
<small v-if="job.lastRun">Last: {{ formatDate(job.lastRun) }}</small>
<small v-if="job.nextRun">Next: {{ formatDate(job.nextRun) }}</small>
</div>
</div>
</template>
<div v-else class="calendar-status">No scheduled jobs</div>
</section>
</div>
</template>
<style scoped>
.calendar-layout {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 12px;
min-height: 360px;
}
.calendar-panel {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
padding: 16px;
}
.calendar-panel-head {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.calendar-panel-head h2 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #e8eaf0;
}
.calendar-status {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
color: #7e8799;
font-size: 11px;
}
.calendar-status.error {
color: #e16e75;
}
/* Upcoming jobs */
.upcoming-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px;
border-radius: 7px;
}
.upcoming-item + .upcoming-item {
border-top: 1px solid var(--line);
}
.upcoming-item-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 6px;
color: #a99cf5;
background: rgba(139,124,246,.1);
}
.upcoming-item-info strong {
display: block;
font-size: 11px;
margin-bottom: 2px;
color: #e8eaf0;
}
.upcoming-item-schedule {
display: block;
font-size: 9px;
color: #7e8799;
margin-bottom: 2px;
}
.upcoming-item-next {
display: block;
font-size: 9px;
color: #a99cf5;
font-weight: 600;
}
/* All jobs */
.job-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px;
border-radius: 7px;
}
.job-item + .job-item {
border-top: 1px solid var(--line);
}
.job-item-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 6px;
}
.job-item-icon.status-completed {
color: #27ae60;
background: rgba(39, 174, 96, 0.12);
}
.job-item-icon.status-running {
color: #3498db;
background: rgba(52, 152, 219, 0.12);
}
.job-item-icon.status-failed {
color: #e74c3c;
background: rgba(231, 76, 60, 0.12);
}
.job-item-icon.status-idle {
color: #7e8799;
background: rgba(126, 135, 153, 0.12);
}
.job-item-info {
flex: 1;
}
.job-item-info strong {
display: block;
font-size: 11px;
margin-bottom: 2px;
color: #e8eaf0;
}
.job-item-id {
display: block;
font-size: 9px;
color: #6b7385;
margin-bottom: 2px;
font-family: monospace;
}
.job-item-schedule {
display: block;
font-size: 9px;
color: #7e8799;
}
.job-item-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
flex-shrink: 0;
}
.job-item-meta small {
font-size: 8px;
color: #6b7385;
text-align: right;
}
.job-status-badge {
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 6px;
border-radius: 4px;
}
.job-status-badge.status-completed {
background: rgba(39, 174, 96, 0.15);
color: #27ae60;
}
.job-status-badge.status-running {
background: rgba(52, 152, 219, 0.15);
color: #3498db;
}
.job-status-badge.status-failed {
background: rgba(231, 76, 60, 0.15);
color: #e74c3c;
}
.job-status-badge.status-idle {
background: rgba(126, 135, 153, 0.15);
color: #7e8799;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 900px) {
.calendar-layout {
grid-template-columns: 1fr;
}
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import IrisPanel from '../components/dashboard/IrisPanel.vue'
import OperationsFeed from '../components/dashboard/OperationsFeed.vue'
import AgendaPanel from '../components/dashboard/AgendaPanel.vue'
import ActiveInitiatives from '../components/dashboard/ActiveInitiatives.vue'
import RecentlyFinished from '../components/dashboard/RecentlyFinished.vue'
</script>
<template>
<div class="dashboard">
<!-- Top Bar -->
<div class="topbar">
<span class="eyebrow">MISSION CONTROL</span>
<h1>Übersicht</h1>
</div>
<!-- Three-column row -->
<div class="columns">
<IrisPanel />
<OperationsFeed />
<AgendaPanel />
</div>
<!-- Bottom sections -->
<ActiveInitiatives />
<RecentlyFinished />
</div>
</template>
<style scoped>
.dashboard {
--panel-bg: rgba(22, 27, 34, 0.8);
--panel-border: rgba(139, 124, 246, 0.12);
--text-primary: #e8eaf0;
--text-secondary: #7e8799;
--text-muted: #6b7385;
--iris-accent: #a78bfa;
--blue: #3b82f6;
--green: #22c55e;
--yellow: #eab308;
--red: #ef4444;
--gray: #6b7280;
--bg-base: #0d1117;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 1280px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: var(--text-primary);
}
.topbar {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
}
.eyebrow {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--iris-accent);
text-transform: uppercase;
}
.topbar h1 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.columns {
display: grid;
grid-template-columns: 280px 1fr 260px;
gap: 10px;
}
@media (max-width: 900px) {
.columns {
grid-template-columns: 1fr;
}
.topbar h1 {
font-size: 16px;
}
}
</style>
+485
View File
@@ -0,0 +1,485 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { BookOpen, Search, ArrowLeft, Clock, FileText, Filter, Loader2 } from '@lucide/vue'
import { apiFetch } from '../services/api'
import { renderMarkdown } from '../utils/markdown'
import type { DocFile, DocDetail } from '../types'
// State
const docs = ref<DocFile[]>([])
const loading = ref(false)
const error = ref('')
const searchQuery = ref('')
const categoryFilter = ref('')
const selectedDoc = ref<DocDetail | null>(null)
const contentLoading = ref(false)
const categories = ['phases', 'skills', 'workspace', 'nexus', 'nexus-phases']
// Filtered + sorted docs (newest first)
const filteredDocs = computed(() => {
let items = [...docs.value]
if (categoryFilter.value) {
items = items.filter(d => d.category === categoryFilter.value)
}
if (searchQuery.value.trim()) {
const q = searchQuery.value.trim().toLowerCase()
items = items.filter(d => d.name.toLowerCase().includes(q) || d.path.toLowerCase().includes(q))
}
return items.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
})
async function loadDocs() {
loading.value = true
error.value = ''
try {
const response = await apiFetch('/api/v1/docs')
if (!response.ok) throw new Error('Failed to load documents')
docs.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load documents'
} finally {
loading.value = false
}
}
async function loadDocContent(path: string) {
contentLoading.value = true
selectedDoc.value = null
try {
const response = await apiFetch(`/api/v1/docs/${encodeURIComponent(path)}`)
if (!response.ok) throw new Error('Failed to load document content')
selectedDoc.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load document content'
} finally {
contentLoading.value = false
}
}
function selectDoc(name: string, path: string) {
loadDocContent(path)
}
function goBack() {
selectedDoc.value = null
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function formatSize(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`
}
onMounted(loadDocs)
</script>
<template>
<div class="page-heading">
<div>
<span class="eyebrow">KNOWLEDGE</span>
<h1>Docs</h1>
<p>Browse documentation, agent skills, and workspace references.</p>
</div>
</div>
<div class="docs-toolbar">
<div class="docs-search-bar">
<Search :size="16" />
<input
v-model="searchQuery"
placeholder="Search documents..."
/>
</div>
<div class="docs-filter-group">
<Filter :size="14" />
<select v-model="categoryFilter">
<option value="">All categories</option>
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
</div>
<div class="memory-layout">
<!-- Left column: document list -->
<aside class="memory-sidebar">
<div v-if="loading" class="memory-status">
<Loader2 :size="16" class="spin" />
Loading documents...
</div>
<div v-else-if="error" class="memory-status error">{{ error }}</div>
<template v-else-if="filteredDocs.length">
<div class="memory-list-header">{{ filteredDocs.length }} documents</div>
<button
v-for="doc in filteredDocs"
:key="doc.name + doc.path"
:class="['memory-file-item', { active: selectedDoc?.name === doc.name && selectedDoc?.path === doc.path }]"
@click="selectDoc(doc.name, doc.path)"
>
<div class="memory-file-icon">
<FileText :size="14" />
</div>
<div class="memory-file-info">
<strong>{{ doc.name }}</strong>
<div class="doc-tags">
<span :class="['doc-category-badge', `cat-${doc.category}`]">{{ doc.category }}</span>
<span class="doc-type-tag">{{ doc.type }}</span>
</div>
<span class="memory-file-meta">
<Clock :size="10" /> {{ formatDate(doc.modifiedAt) }}
· {{ formatSize(doc.size) }}
</span>
</div>
</button>
</template>
<div v-else class="memory-status">No documents match your filters</div>
</aside>
<!-- Right column: content -->
<main class="memory-content">
<template v-if="contentLoading">
<div class="memory-status">
<Loader2 :size="20" class="spin" />
Loading content...
</div>
</template>
<template v-else-if="selectedDoc">
<header class="memory-content-header">
<button class="memory-back-btn" @click="goBack">
<ArrowLeft :size="14" />
Back
</button>
<div>
<strong>{{ selectedDoc.name }}</strong>
<div class="doc-tags">
<span :class="['doc-category-badge', `cat-${selectedDoc.category}`]">{{ selectedDoc.category }}</span>
<span class="doc-type-tag">{{ selectedDoc.type }}</span>
</div>
<span class="memory-content-meta">
<Clock :size="10" /> {{ formatDate(selectedDoc.modifiedAt) }}
· {{ formatSize(selectedDoc.size) }}
</span>
</div>
</header>
<article
class="memory-rendered"
v-html="renderMarkdown(selectedDoc.content)"
></article>
</template>
<template v-else>
<div class="memory-empty-state">
<BookOpen :size="28" />
<h3>Select a document</h3>
<p>Choose a document from the list or use the search/filter to find what you need.</p>
</div>
</template>
</main>
</div>
</template>
<style scoped>
.docs-toolbar {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.docs-search-bar {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 9px;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: #6f7889;
}
.docs-search-bar input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: #e8eaf0;
font: inherit;
font-size: 13px;
}
.docs-filter-group {
display: flex;
align-items: center;
gap: 7px;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: #8991a1;
}
.docs-filter-group select {
border: none;
outline: none;
background: transparent;
color: #e8eaf0;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.memory-layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 12px;
min-height: 480px;
}
.memory-sidebar {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
padding: 8px;
max-height: 640px;
overflow-y: auto;
}
.memory-list-header {
font-size: 9px;
font-weight: 700;
color: #7065c8;
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 10px 8px 6px;
}
.memory-file-item {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px;
border: none;
border-radius: 7px;
background: transparent;
color: #e8eaf0;
text-align: left;
cursor: pointer;
font: inherit;
}
.memory-file-item:hover,
.memory-file-item.active {
background: var(--accent-soft);
}
.memory-file-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 6px;
color: #a99cf5;
background: rgba(139,124,246,.1);
}
.memory-file-info strong {
display: block;
font-size: 11px;
margin-bottom: 3px;
word-break: break-word;
}
.doc-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 3px;
}
.doc-category-badge {
font-size: 8px;
padding: 1px 6px;
border-radius: 6px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.doc-category-badge.cat-phases {
background: rgba(139,124,246,.12);
color: #a99cf5;
}
.doc-category-badge.cat-skills {
background: rgba(81,212,154,.1);
color: #51d49a;
}
.doc-category-badge.cat-workspace {
background: rgba(229,176,94,.1);
color: #e5b05e;
}
.doc-category-badge.cat-nexus {
background: rgba(109,159,230,.1);
color: #6d9fe6;
}
.doc-category-badge.cat-nexus-phases {
background: rgba(225,110,117,.1);
color: #e16e75;
}
.doc-type-tag {
font-size: 8px;
padding: 1px 5px;
border: 1px solid #343947;
border-radius: 4px;
color: #8991a1;
}
.memory-file-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
}
.memory-content {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
padding: 24px;
min-height: 480px;
overflow-y: auto;
}
.memory-content-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.memory-content-header > div strong {
display: block;
font-size: 13px;
margin-bottom: 4px;
}
.memory-content-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
margin-top: 4px;
}
.memory-back-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: #8991a1;
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
}
.memory-back-btn:hover {
color: #e8eaf0;
background: var(--accent-soft);
}
.memory-status {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
color: #7e8799;
font-size: 11px;
}
.memory-status.error {
color: #e16e75;
}
.memory-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-height: 360px;
color: #6b7385;
}
.memory-empty-state h3 {
margin: 12px 0 6px;
font-size: 14px;
color: #a5adba;
}
.memory-empty-state p {
margin: 0;
font-size: 10px;
color: #7e8799;
}
.memory-rendered {
font-size: 12px;
line-height: 1.7;
color: #d0d4dd;
}
.memory-rendered :deep(h1),
.memory-rendered :deep(h2),
.memory-rendered :deep(h3) {
color: #e8eaf0;
margin: 1.2em 0 0.5em;
}
.memory-rendered :deep(h1) { font-size: 1.3rem; }
.memory-rendered :deep(h2) { font-size: 1.1rem; }
.memory-rendered :deep(h3) { font-size: 1rem; }
.memory-rendered :deep(p) { margin: 0.6em 0; }
.memory-rendered :deep(code) {
padding: 2px 5px;
background: rgba(139,124,246,.08);
border-radius: 4px;
font-size: 11px;
}
.memory-rendered :deep(pre) {
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--line);
overflow-x: auto;
}
.memory-rendered :deep(pre code) {
background: none;
padding: 0;
}
.memory-rendered :deep(a) {
color: #a99cf5;
text-decoration: none;
}
.memory-rendered :deep(a:hover) {
text-decoration: underline;
}
.memory-rendered :deep(ul) {
padding-left: 20px;
}
.memory-rendered :deep(li) {
margin: 0.3em 0;
}
.memory-rendered :deep(hr) {
border: none;
border-top: 1px solid var(--line);
margin: 1.2em 0;
}
.memory-rendered :deep(strong) {
color: #e8eaf0;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 900px) {
.memory-layout {
grid-template-columns: 1fr;
}
.memory-sidebar {
max-height: 280px;
}
}
</style>
+456
View File
@@ -0,0 +1,456 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { FileText, AlertTriangle, AlertCircle, Info, Activity, ArrowLeft, Clock, Loader2 } from '@lucide/vue'
import { apiFetch } from '../services/api'
import { renderMarkdown } from '../utils/markdown'
import { useRouter } from 'vue-router'
interface IncidentFile {
name: string
title: string
date: string | null
severity: string
excerpt: string
size: number
}
interface IncidentDetail extends IncidentFile {
content: string
}
const router = useRouter()
// State
const incidents = ref<IncidentFile[]>([])
const loading = ref(false)
const error = ref('')
const selectedIncident = ref<IncidentDetail | null>(null)
const contentLoading = ref(false)
// Sorted incidents (newest first by date)
const sortedIncidents = computed(() => {
return [...incidents.value].sort((a, b) => {
if (a.date && b.date) return b.date.localeCompare(a.date)
if (a.date) return -1
if (b.date) return 1
return a.name.localeCompare(b.name)
})
})
// Severity badge mapping
function severityClass(s: string): string {
const lower = s.toLowerCase()
if (lower.includes('critical')) return 'sev-critical'
if (lower.includes('major')) return 'sev-major'
if (lower.includes('minor')) return 'sev-minor'
return 'sev-unknown'
}
function severityIcon(s: string) {
const lower = s.toLowerCase()
if (lower.includes('critical')) return AlertCircle
if (lower.includes('major')) return AlertTriangle
return Info
}
async function loadIncidents() {
loading.value = true
error.value = ''
try {
const response = await apiFetch('/api/v1/incidents')
if (!response.ok) throw new Error('Failed to load incidents')
incidents.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load incidents'
} finally {
loading.value = false
}
}
async function loadIncidentContent(name: string) {
contentLoading.value = true
selectedIncident.value = null
try {
const response = await apiFetch(`/api/v1/incidents/${encodeURIComponent(name)}`)
if (!response.ok) throw new Error('Failed to load incident')
selectedIncident.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load incident'
} finally {
contentLoading.value = false
}
}
function selectIncident(name: string) {
loadIncidentContent(name)
}
function goBack() {
selectedIncident.value = null
}
function formatDate(dateStr: string): string {
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
year: 'numeric', month: 'short', day: 'numeric',
})
}
function formatSize(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`
}
onMounted(loadIncidents)
</script>
<template>
<div class="page-heading">
<div>
<span class="eyebrow">OPERATIONS</span>
<h1>Incidents</h1>
<p>Post-mortem reports and operational incidents across Noveria.</p>
</div>
</div>
<div class="incident-layout">
<!-- Left column: incident list -->
<aside class="incident-sidebar">
<template v-if="loading">
<div class="incident-status">
<Loader2 :size="16" class="spin" />
Loading incidents...
</div>
</template>
<template v-else-if="error">
<div class="incident-status error">{{ error }}</div>
</template>
<template v-else-if="sortedIncidents.length">
<div class="incident-list-header">{{ sortedIncidents.length }} reports</div>
<button
v-for="inc in sortedIncidents"
:key="inc.name"
:class="['incident-file-item', { active: selectedIncident?.name === inc.name }]"
@click="selectIncident(inc.name)"
>
<div class="incident-file-icon" :class="severityClass(inc.severity)">
<component :is="severityIcon(inc.severity)" :size="14" />
</div>
<div class="incident-file-info">
<strong>{{ inc.title }}</strong>
<span class="incident-file-meta">
<Clock :size="10" />
{{ inc.date ? formatDate(inc.date) : 'Unknown date' }}
<span :class="['severity-badge', severityClass(inc.severity)]">{{ inc.severity }}</span>
</span>
<span class="incident-file-excerpt">{{ inc.excerpt }}</span>
</div>
</button>
</template>
<div v-else class="incident-status">No incidents recorded</div>
</aside>
<!-- Right column: detail view -->
<main class="incident-content">
<template v-if="contentLoading">
<div class="incident-status">
<Loader2 :size="20" class="spin" />
Loading incident...
</div>
</template>
<template v-else-if="selectedIncident">
<header class="incident-content-header">
<button class="incident-back-btn" @click="goBack">
<ArrowLeft :size="14" />
Back
</button>
<div>
<div class="incident-content-title-row">
<strong>{{ selectedIncident.title }}</strong>
<span :class="['severity-badge', severityClass(selectedIncident.severity)]">{{ selectedIncident.severity }}</span>
</div>
<span class="incident-content-meta">
<Clock :size="10" />
{{ selectedIncident.date ? formatDate(selectedIncident.date) : 'Unknown date' }}
· {{ formatSize(selectedIncident.size) }}
</span>
</div>
</header>
<article
class="incident-rendered"
v-html="renderMarkdown(selectedIncident.content)"
></article>
</template>
<template v-else>
<div class="incident-empty-state">
<AlertTriangle :size="28" />
<h3>Select an incident report</h3>
<p>Choose a report from the list to view its post-mortem details.</p>
</div>
</template>
</main>
</div>
</template>
<style scoped>
.incident-layout {
display: grid;
grid-template-columns: 360px 1fr;
gap: 12px;
min-height: 480px;
}
.incident-sidebar {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
padding: 8px;
max-height: 640px;
overflow-y: auto;
}
.incident-list-header {
font-size: 9px;
font-weight: 700;
color: #7065c8;
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 10px 8px 6px;
}
.incident-file-item {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px;
border: none;
border-radius: 7px;
background: transparent;
color: #e8eaf0;
text-align: left;
cursor: pointer;
font: inherit;
}
.incident-file-item:hover,
.incident-file-item.active {
background: var(--accent-soft);
}
.incident-file-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 6px;
}
.incident-file-icon.sev-critical {
color: #e74c3c;
background: rgba(231, 76, 60, 0.12);
}
.incident-file-icon.sev-major {
color: #e67e22;
background: rgba(230, 126, 34, 0.12);
}
.incident-file-icon.sev-minor {
color: #f1c40f;
background: rgba(241, 196, 15, 0.12);
}
.incident-file-icon.sev-unknown {
color: #7e8799;
background: rgba(126, 135, 153, 0.12);
}
.incident-file-info strong {
display: block;
font-size: 11px;
margin-bottom: 3px;
word-break: break-word;
}
.incident-file-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
margin-bottom: 4px;
}
.incident-file-excerpt {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 9px;
color: #6b7385;
line-height: 1.5;
}
.severity-badge {
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 6px;
border-radius: 4px;
}
.severity-badge.sev-critical {
background: rgba(231, 76, 60, 0.15);
color: #e74c3c;
}
.severity-badge.sev-major {
background: rgba(230, 126, 34, 0.15);
color: #e67e22;
}
.severity-badge.sev-minor {
background: rgba(241, 196, 15, 0.15);
color: #f1c40f;
}
.severity-badge.sev-unknown {
background: rgba(126, 135, 153, 0.15);
color: #7e8799;
}
.incident-content {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
padding: 24px;
min-height: 480px;
overflow-y: auto;
}
.incident-content-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.incident-content-title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 3px;
}
.incident-content-title-row strong {
font-size: 13px;
}
.incident-content-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
}
.incident-back-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: #8991a1;
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
}
.incident-back-btn:hover {
color: #e8eaf0;
background: var(--accent-soft);
}
.incident-status {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
color: #7e8799;
font-size: 11px;
}
.incident-status.error {
color: #e16e75;
}
.incident-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-height: 360px;
color: #6b7385;
}
.incident-empty-state h3 {
margin: 12px 0 6px;
font-size: 14px;
color: #a5adba;
}
.incident-empty-state p {
margin: 0;
font-size: 10px;
color: #7e8799;
}
.incident-rendered {
font-size: 12px;
line-height: 1.7;
color: #d0d4dd;
}
.incident-rendered :deep(h1),
.incident-rendered :deep(h2),
.incident-rendered :deep(h3) {
color: #e8eaf0;
margin: 1.2em 0 0.5em;
}
.incident-rendered :deep(h1) { font-size: 1.3rem; }
.incident-rendered :deep(h2) { font-size: 1.1rem; }
.incident-rendered :deep(h3) { font-size: 1rem; }
.incident-rendered :deep(p) { margin: 0.6em 0; }
.incident-rendered :deep(code) {
padding: 2px 5px;
background: rgba(139,124,246,.08);
border-radius: 4px;
font-size: 11px;
}
.incident-rendered :deep(pre) {
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--line);
overflow-x: auto;
}
.incident-rendered :deep(pre code) {
background: none;
padding: 0;
}
.incident-rendered :deep(a) {
color: #a99cf5;
text-decoration: none;
}
.incident-rendered :deep(a:hover) {
text-decoration: underline;
}
.incident-rendered :deep(ul) {
padding-left: 20px;
}
.incident-rendered :deep(li) {
margin: 0.3em 0;
}
.incident-rendered :deep(hr) {
border: none;
border-top: 1px solid var(--line);
margin: 1.2em 0;
}
.incident-rendered :deep(strong) {
color: #e8eaf0;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 900px) {
.incident-layout {
grid-template-columns: 1fr;
}
.incident-sidebar {
max-height: 280px;
}
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Command, LockKeyhole } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const email = ref('')
const password = ref('')
const error = ref('')
async function submit() {
error.value = ''
try {
await auth.login(email.value.trim(), password.value)
const target = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
? route.query.redirect
: '/dashboard'
await router.replace(target)
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Login failed.'
}
}
</script>
<template>
<main class="login-page">
<section class="login-card">
<div class="login-brand">
<div class="brand-mark"><Command :size="20" /></div>
<div><strong>NEXUS</strong><span>Noveria Operations</span></div>
</div>
<div class="login-heading">
<span class="eyebrow">OWNER ACCESS</span>
<h1>Sign in to mission control</h1>
<p>Use your private owner credentials to continue.</p>
</div>
<form @submit.prevent="submit">
<label>
<span>Email</span>
<input v-model="email" type="email" autocomplete="username" required maxlength="120" />
</label>
<label>
<span>Password</span>
<input v-model="password" type="password" autocomplete="current-password" required minlength="10" maxlength="200" />
</label>
<p v-if="error" class="login-error" role="alert">{{ error }}</p>
<button type="submit" :disabled="auth.loading">
<LockKeyhole :size="15" />
{{ auth.loading ? 'Signing in...' : 'Sign in' }}
</button>
</form>
<footer>Protected owner session · Refresh token stored in a secure HTTP-only cookie</footer>
</section>
</main>
</template>
+463
View File
@@ -0,0 +1,463 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { FileText, Search, ArrowLeft, Clock, Database, Loader2 } from '@lucide/vue'
import { apiFetch } from '../services/api'
import { renderMarkdown } from '../utils/markdown'
import type { MemoryFile, MemoryDetail, MemorySearchResult } from '../types'
// State
const memories = ref<MemoryFile[]>([])
const loading = ref(false)
const error = ref('')
const searchQuery = ref('')
const searchResults = ref<MemorySearchResult[]>([])
const searchLoading = ref(false)
const searchDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
const selectedMemory = ref<MemoryDetail | null>(null)
const contentLoading = ref(false)
// Sorted memories (newest first)
const sortedMemories = computed(() => {
return [...memories.value].sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
})
async function loadMemories() {
loading.value = true
error.value = ''
try {
const response = await apiFetch('/api/v1/memory')
if (!response.ok) throw new Error('Failed to load memory files')
memories.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load memory files'
} finally {
loading.value = false
}
}
async function loadMemoryContent(name: string) {
contentLoading.value = true
selectedMemory.value = null
try {
const response = await apiFetch(`/api/v1/memory/${encodeURIComponent(name)}`)
if (!response.ok) throw new Error('Failed to load memory content')
selectedMemory.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load memory content'
} finally {
contentLoading.value = false
}
}
async function doSearch(q: string) {
if (q.length < 2) {
searchResults.value = []
return
}
searchLoading.value = true
try {
const response = await apiFetch(`/api/v1/memory/search?q=${encodeURIComponent(q)}`)
if (!response.ok) throw new Error('Search failed')
searchResults.value = await response.json()
} catch (e) {
searchResults.value = []
} finally {
searchLoading.value = false
}
}
function onSearchInput() {
if (searchDebounce.value) clearTimeout(searchDebounce.value)
const q = searchQuery.value.trim()
if (q.length < 2) {
searchResults.value = []
return
}
searchDebounce.value = setTimeout(() => doSearch(q), 300)
}
function selectMemory(name: string) {
searchResults.value = []
searchQuery.value = ''
loadMemoryContent(name)
}
function goBack() {
selectedMemory.value = null
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function formatSize(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`
}
onMounted(loadMemories)
</script>
<template>
<div class="page-heading">
<div>
<span class="eyebrow">LIFECYCLE</span>
<h1>Memory</h1>
<p>Browse agent memory files stored across workspaces.</p>
</div>
</div>
<div class="memory-search-bar">
<Search :size="16" />
<input
v-model="searchQuery"
placeholder="Search memory files..."
@input="onSearchInput"
/>
<kbd v-if="!searchQuery">Type at least 2 characters</kbd>
</div>
<div class="memory-layout">
<!-- Left column: file list or search results -->
<aside class="memory-sidebar">
<!-- Search results -->
<template v-if="searchQuery.trim().length >= 2">
<div v-if="searchLoading" class="memory-status">
<Loader2 :size="16" class="spin" />
Searching...
</div>
<template v-else-if="searchResults.length">
<div class="memory-list-header">Search results ({{ searchResults.length }})</div>
<button
v-for="result in searchResults"
:key="result.name"
class="memory-file-item"
@click="selectMemory(result.name)"
>
<div class="memory-file-icon"><FileText :size="14" /></div>
<div class="memory-file-info">
<strong>{{ result.name }}</strong>
<span class="memory-file-excerpt">{{ result.excerpt }}</span>
</div>
</button>
</template>
<div v-else class="memory-status">No results found</div>
</template>
<!-- File list (default) -->
<template v-else>
<div v-if="loading" class="memory-status">
<Loader2 :size="16" class="spin" />
Loading memory files...
</div>
<div v-else-if="error" class="memory-status error">{{ error }}</div>
<template v-else-if="sortedMemories.length">
<div class="memory-list-header">{{ sortedMemories.length }} files</div>
<button
v-for="mem in sortedMemories"
:key="mem.name"
:class="['memory-file-item', { active: selectedMemory?.name === mem.name }]"
@click="loadMemoryContent(mem.name)"
>
<div class="memory-file-icon"><FileText :size="14" /></div>
<div class="memory-file-info">
<strong>{{ mem.name }}</strong>
<span class="memory-file-meta">
<Clock :size="10" /> {{ formatDate(mem.modifiedAt) }}
· {{ formatSize(mem.size) }}
</span>
</div>
</button>
</template>
<div v-else class="memory-status">No memory files available</div>
</template>
</aside>
<!-- Right column: content -->
<main class="memory-content">
<template v-if="contentLoading">
<div class="memory-status">
<Loader2 :size="20" class="spin" />
Loading content...
</div>
</template>
<template v-else-if="selectedMemory">
<header class="memory-content-header">
<button class="memory-back-btn" @click="goBack">
<ArrowLeft :size="14" />
Back
</button>
<div>
<strong>{{ selectedMemory.name }}</strong>
<span class="memory-content-meta">
<Clock :size="10" /> {{ formatDate(selectedMemory.modifiedAt) }}
· {{ formatSize(selectedMemory.size) }}
</span>
</div>
</header>
<article
class="memory-rendered"
v-html="renderMarkdown(selectedMemory.content)"
></article>
</template>
<template v-else>
<div class="memory-empty-state">
<Database :size="28" />
<h3>Select a memory file</h3>
<p>Choose a file from the list or search to view its contents.</p>
</div>
</template>
</main>
</div>
</template>
<style scoped>
.memory-search-bar {
display: flex;
align-items: center;
gap: 9px;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
margin-bottom: 16px;
color: #6f7889;
}
.memory-search-bar input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: #e8eaf0;
font: inherit;
font-size: 13px;
}
.memory-search-bar kbd {
padding: 2px 5px;
border: 1px solid #2c313d;
border-radius: 4px;
color: #606979;
font-size: 9px;
}
.memory-layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 12px;
min-height: 480px;
}
.memory-sidebar {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
padding: 8px;
max-height: 640px;
overflow-y: auto;
}
.memory-list-header {
font-size: 9px;
font-weight: 700;
color: #7065c8;
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 10px 8px 6px;
}
.memory-file-item {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px;
border: none;
border-radius: 7px;
background: transparent;
color: #e8eaf0;
text-align: left;
cursor: pointer;
font: inherit;
}
.memory-file-item:hover,
.memory-file-item.active {
background: var(--accent-soft);
}
.memory-file-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 6px;
color: #a99cf5;
background: rgba(139,124,246,.1);
}
.memory-file-info strong {
display: block;
font-size: 11px;
margin-bottom: 3px;
word-break: break-word;
}
.memory-file-meta,
.memory-file-excerpt {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
line-height: 1.5;
}
.memory-file-excerpt {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.memory-content {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
padding: 24px;
min-height: 480px;
overflow-y: auto;
}
.memory-content-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.memory-content-header > div strong {
display: block;
font-size: 13px;
margin-bottom: 3px;
}
.memory-content-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
}
.memory-back-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: #8991a1;
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
}
.memory-back-btn:hover {
color: #e8eaf0;
background: var(--accent-soft);
}
.memory-status {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
color: #7e8799;
font-size: 11px;
}
.memory-status.error {
color: #e16e75;
}
.memory-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-height: 360px;
color: #6b7385;
}
.memory-empty-state h3 {
margin: 12px 0 6px;
font-size: 14px;
color: #a5adba;
}
.memory-empty-state p {
margin: 0;
font-size: 10px;
color: #7e8799;
}
.memory-rendered {
font-size: 12px;
line-height: 1.7;
color: #d0d4dd;
}
.memory-rendered :deep(h1),
.memory-rendered :deep(h2),
.memory-rendered :deep(h3) {
color: #e8eaf0;
margin: 1.2em 0 0.5em;
}
.memory-rendered :deep(h1) { font-size: 1.3rem; }
.memory-rendered :deep(h2) { font-size: 1.1rem; }
.memory-rendered :deep(h3) { font-size: 1rem; }
.memory-rendered :deep(p) { margin: 0.6em 0; }
.memory-rendered :deep(code) {
padding: 2px 5px;
background: rgba(139,124,246,.08);
border-radius: 4px;
font-size: 11px;
}
.memory-rendered :deep(pre) {
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--line);
overflow-x: auto;
}
.memory-rendered :deep(pre code) {
background: none;
padding: 0;
}
.memory-rendered :deep(a) {
color: #a99cf5;
text-decoration: none;
}
.memory-rendered :deep(a:hover) {
text-decoration: underline;
}
.memory-rendered :deep(ul) {
padding-left: 20px;
}
.memory-rendered :deep(li) {
margin: 0.3em 0;
}
.memory-rendered :deep(hr) {
border: none;
border-top: 1px solid var(--line);
margin: 1.2em 0;
}
.memory-rendered :deep(strong) {
color: #e8eaf0;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 900px) {
.memory-layout {
grid-template-columns: 1fr;
}
.memory-sidebar {
max-height: 280px;
}
}
</style>
+481
View File
@@ -0,0 +1,481 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Edit2, Save, Trash2, X, CheckCircle2, ShieldAlert, Clock3 } from '@lucide/vue'
import type { TaskState } from '../types'
import { apiFetch } from '../services/api'
interface Project {
id: string
name: string
description: string
status: string
progress: number
updatedAt: string
}
interface Task {
id: string
title: string
state: TaskState
priority: string
projectId?: string | null
updatedAt: string
}
const route = useRoute()
const router = useRouter()
const project = ref<Project | null>(null)
const tasks = ref<Task[]>([])
const loading = ref(true)
const error = ref('')
const editing = ref(false)
const editName = ref('')
const editDescription = ref('')
const showArchive = ref(false)
const archiving = ref(false)
const projectId = computed(() => route.params.id as string)
async function loadProject() {
loading.value = true
error.value = ''
try {
const [projectResponse, tasksResponse] = await Promise.all([
apiFetch(`/api/v1/projects/${projectId.value}`),
apiFetch('/api/v1/tasks'),
])
if (!projectResponse.ok) throw new Error('Project not found')
project.value = await projectResponse.json()
if (tasksResponse.ok) {
const allTasks: Task[] = await tasksResponse.json()
tasks.value = allTasks.filter(t => t.projectId === projectId.value || t.projectId === null)
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load project'
} finally {
loading.value = false
}
}
function startEditing() {
if (!project.value) return
editName.value = project.value.name
editDescription.value = project.value.description
editing.value = true
}
async function saveEdit() {
if (!project.value) return
try {
const response = await apiFetch(`/api/v1/projects/${projectId.value}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editName.value.trim() || undefined,
description: editDescription.value.trim() || undefined,
}),
})
if (!response.ok) throw new Error('Failed to update project')
project.value = await response.json()
editing.value = false
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to save'
}
}
function cancelEdit() {
editing.value = false
}
async function archiveProject() {
if (!project.value) return
archiving.value = true
try {
const response = await apiFetch(`/api/v1/projects/${projectId.value}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'Offline' }),
})
if (!response.ok) throw new Error('Failed to archive project')
project.value = await response.json()
showArchive.value = false
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to archive'
} finally {
archiving.value = false
}
}
function getTaskStateIcon(state: TaskState) {
if (state === 'Done') return CheckCircle2
if (state === 'Blocked') return ShieldAlert
return Clock3
}
onMounted(loadProject)
</script>
<template>
<div class="project-detail">
<header class="detail-header">
<button class="back-btn" @click="router.push('/projects')">
<ArrowLeft :size="17" />
Back to Projects
</button>
</header>
<div v-if="loading" class="loading-state">Loading project...</div>
<div v-else-if="error" class="error-state">{{ error }}</div>
<template v-else-if="project">
<div class="project-detail-card">
<div class="project-detail-top">
<div class="project-letter-lg">{{ project.name[0] }}</div>
<div class="project-detail-info">
<template v-if="editing">
<input v-model="editName" class="edit-input" maxlength="160" />
<textarea v-model="editDescription" class="edit-textarea" maxlength="1000" rows="2" placeholder="Description"></textarea>
<div class="edit-actions">
<button class="btn-save" @click="saveEdit"><Save :size="14" /> Save</button>
<button class="btn-cancel" @click="cancelEdit"><X :size="14" /> Cancel</button>
</div>
</template>
<template v-else>
<h1>{{ project.name }}</h1>
<p class="project-description">{{ project.description || 'No description' }}</p>
<div class="project-meta">
<span :class="['badge', project.status === 'Online' || project.status === 'Active' ? 'positive' : 'warning']">{{ project.status }}</span>
<span class="updated-at">Updated {{ new Date(project.updatedAt).toLocaleString() }}</span>
</div>
</template>
</div>
<div class="project-detail-actions" v-if="!editing">
<button class="btn-icon" @click="startEditing" title="Edit project"><Edit2 :size="16" /></button>
<button class="btn-icon btn-danger" @click="showArchive = true" title="Archive project"><Trash2 :size="16" /></button>
</div>
</div>
<div class="progress-section">
<div class="progress-header">
<span>Progress</span>
<strong>{{ project.progress }}%</strong>
</div>
<div class="progress-bar">
<i :style="{ width: `${project.progress}%` }"></i>
</div>
</div>
</div>
<div class="project-tasks-section">
<h2>Tasks</h2>
<div v-if="!tasks.length" class="empty-tasks">No tasks associated with this project.</div>
<div v-else class="task-list">
<article v-for="task in tasks" :key="task.id" class="task-item">
<component :is="getTaskStateIcon(task.state)" :size="16" :class="['task-icon', task.state.toLowerCase().replace(' ', '-')]" />
<div class="task-info">
<strong>{{ task.title }}</strong>
<span>{{ task.state }} · {{ task.priority }}</span>
</div>
<small>{{ new Date(task.updatedAt).toLocaleDateString() }}</small>
</article>
</div>
</div>
</template>
<div v-if="showArchive" class="modal-overlay" @click.self="showArchive = false">
<div class="modal">
<h3>Archive project?</h3>
<p>This will set the project status to Archived. Tasks will be preserved but the project will no longer appear in the active list.</p>
<div class="modal-actions">
<button class="btn-cancel" @click="showArchive = false" :disabled="archiving">Cancel</button>
<button class="btn-danger" @click="archiveProject" :disabled="archiving">{{ archiving ? 'Archiving...' : 'Archive' }}</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.project-detail {
max-width: 800px;
}
.detail-header {
margin-bottom: 1.25rem;
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: none;
border: none;
color: var(--text-secondary);
font-size: 0.875rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
border-radius: 6px;
transition: background 0.15s;
}
.back-btn:hover {
background: var(--surface-raised);
color: var(--text-primary);
}
.project-detail-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.project-detail-top {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.project-letter-lg {
width: 48px;
height: 48px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
color: #fff;
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.project-detail-info {
flex: 1;
}
.project-detail-info h1 {
font-size: 1.4rem;
font-weight: 600;
margin: 0 0 0.35rem;
color: var(--text-primary);
}
.project-description {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0 0 0.75rem;
line-height: 1.5;
}
.project-meta {
display: flex;
align-items: center;
gap: 0.75rem;
}
.updated-at {
font-size: 0.8rem;
color: var(--text-muted);
}
.project-detail-actions {
display: flex;
gap: 0.5rem;
}
.btn-icon {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem;
cursor: pointer;
color: var(--text-secondary);
display: flex;
transition: all 0.15s;
}
.btn-icon:hover {
background: var(--accent-soft);
color: var(--accent);
}
.btn-icon.btn-danger:hover {
background: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
border-color: rgba(239, 68, 68, 0.3);
}
.edit-input {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1.1rem;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.edit-textarea {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.9rem;
color: var(--text-primary);
resize: vertical;
font-family: inherit;
margin-bottom: 0.5rem;
}
.edit-actions {
display: flex;
gap: 0.5rem;
}
.btn-save {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.8rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
}
.btn-cancel {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.8rem;
background: var(--surface-raised);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
}
.progress-section {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.4rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.progress-bar {
height: 8px;
background: var(--surface-raised);
border-radius: 4px;
overflow: hidden;
}
.progress-bar i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-secondary));
border-radius: 4px;
transition: width 0.3s;
}
.project-tasks-section {
margin-top: 1.5rem;
}
.project-tasks-section h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.75rem;
color: var(--text-primary);
}
.empty-tasks {
color: var(--text-muted);
font-size: 0.9rem;
padding: 1.5rem;
text-align: center;
background: var(--surface);
border: 1px dashed var(--border);
border-radius: 8px;
}
.task-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.task-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
}
.task-icon {
flex-shrink: 0;
}
.task-icon.done { color: rgb(34, 197, 94); }
.task-icon.blocked { color: rgb(239, 68, 68); }
.task-icon.backlog { color: var(--text-muted); }
.task-icon.in-progress { color: var(--accent); }
.task-info {
flex: 1;
}
.task-info strong {
display: block;
font-size: 0.9rem;
color: var(--text-primary);
}
.task-info span {
font-size: 0.8rem;
color: var(--text-muted);
}
.task-item small {
font-size: 0.75rem;
color: var(--text-muted);
flex-shrink: 0;
}
.loading-state, .error-state {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.error-state {
color: rgb(239, 68, 68);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
max-width: 420px;
width: 90%;
}
.modal h3 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.modal p {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0 0 1rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.8rem;
background: rgb(239, 68, 68);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
}
.btn-danger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+332
View File
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import {
Shield, Loader2, KeyRound, Timer, Gauge, Lock,
Cookie, Fingerprint, ShieldCheck, CheckCircle2, XCircle,
} from '@lucide/vue'
import { apiFetch } from '../services/api'
import type { SecurityStatus } from '../types'
const status = ref<SecurityStatus | null>(null)
const loading = ref(false)
const error = ref('')
async function loadStatus() {
loading.value = true
error.value = ''
try {
const response = await apiFetch('/api/v1/security/status')
if (!response.ok) throw new Error('Failed to load security status')
status.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load security status'
} finally {
loading.value = false
}
}
onMounted(loadStatus)
</script>
<template>
<div class="page-heading">
<div>
<span class="eyebrow">SECURITY</span>
<h1>Security Center</h1>
<p>Authentication configuration, token policy, and access controls.</p>
</div>
</div>
<div v-if="loading" class="memory-status">
<Loader2 :size="20" class="spin" />
Loading security status...
</div>
<div v-else-if="error" class="memory-status error">{{ error }}</div>
<template v-else-if="status">
<div class="security-grid">
<!-- Auth Method -->
<article class="security-card">
<div class="security-card-icon">
<KeyRound :size="20" />
</div>
<h3>Authentication</h3>
<div class="security-value">{{ status.authMethod }}</div>
<p class="security-desc">
JWT-based authentication with PBKDF2 password hashing and refresh token rotation.
</p>
</article>
<!-- Token Configuration -->
<article class="security-card">
<div class="security-card-icon">
<Timer :size="20" />
</div>
<h3>Token Configuration</h3>
<div class="security-detail-list">
<div class="security-detail-row">
<span class="security-detail-label">Issuer</span>
<code class="security-detail-value">{{ status.tokenConfig.issuer }}</code>
</div>
<div class="security-detail-row">
<span class="security-detail-label">Audience</span>
<code class="security-detail-value">{{ status.tokenConfig.audience }}</code>
</div>
<div class="security-detail-row">
<span class="security-detail-label">Access Token</span>
<span class="security-detail-value">{{ status.tokenConfig.accessTokenMinutes }} min</span>
</div>
<div class="security-detail-row">
<span class="security-detail-label">Refresh Token</span>
<span class="security-detail-value">{{ status.tokenConfig.refreshTokenDays }} days</span>
</div>
</div>
</article>
<!-- Rate Limiting -->
<article class="security-card">
<div class="security-card-icon">
<Gauge :size="20" />
</div>
<h3>Rate Limiting</h3>
<div class="security-value">{{ status.rateLimit }}</div>
<p class="security-desc">
Requests are throttled per IP and endpoint to prevent abuse and brute force attacks.
</p>
</article>
<!-- Password Policy -->
<article class="security-card">
<div class="security-card-icon">
<Lock :size="20" />
</div>
<h3>Password Policy</h3>
<div class="security-value policy-text">{{ status.passwordPolicy }}</div>
<p class="security-desc">
Enforced at registration and password change. Minimum length and complexity requirements.
</p>
</article>
<!-- Cookie Configuration -->
<article class="security-card">
<div class="security-card-icon">
<Cookie :size="20" />
</div>
<h3>Cookie Configuration</h3>
<div class="security-detail-list">
<div class="security-detail-row">
<span class="security-detail-label">HttpOnly</span>
<span :class="['status-bool', status.cookieConfig.httpOnly ? 'enabled' : 'disabled']">
{{ status.cookieConfig.httpOnly ? 'Yes' : 'No' }}
</span>
</div>
<div class="security-detail-row">
<span class="security-detail-label">Secure</span>
<span :class="['status-bool', status.cookieConfig.secure ? 'enabled' : 'disabled']">
{{ status.cookieConfig.secure ? 'Yes' : 'No' }}
</span>
</div>
<div class="security-detail-row">
<span class="security-detail-label">SameSite</span>
<span class="security-detail-value">{{ status.cookieConfig.sameSite }}</span>
</div>
</div>
<p class="security-desc">
Refresh tokens stored in secure HTTP-only cookies, accessible only server-side.
</p>
</article>
<!-- 2FA Status -->
<article class="security-card">
<div class="security-card-icon twofa-icon">
<Fingerprint :size="20" />
</div>
<h3>Two-Factor Authentication</h3>
<div class="security-status-row">
<template v-if="status.twoFactorEnabled">
<CheckCircle2 :size="16" class="check-icon" />
<span class="security-value enabled-text">Enabled</span>
</template>
<template v-else>
<XCircle :size="16" class="x-icon" />
<span class="security-value disabled-text">Disabled</span>
</template>
</div>
<p class="security-desc">
{{ status.twoFactorEnabled
? '2FA adds an extra layer of security to owner accounts.'
: 'Two-factor authentication is not currently configured.' }}
</p>
</article>
<!-- Passkey Status -->
<article class="security-card">
<div class="security-card-icon passkey-icon">
<ShieldCheck :size="20" />
</div>
<h3>Passkey Authentication</h3>
<div class="security-status-row">
<template v-if="status.passkeyEnabled">
<CheckCircle2 :size="16" class="check-icon" />
<span class="security-value enabled-text">Enabled</span>
</template>
<template v-else>
<XCircle :size="16" class="x-icon" />
<span class="security-value disabled-text">Disabled</span>
</template>
</div>
<p class="security-desc">
{{ status.passkeyEnabled
? 'WebAuthn-based passkey authentication is available.'
: 'Passkey authentication is not currently configured.' }}
</p>
</article>
</div>
</template>
</template>
<style scoped>
.security-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.security-card {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.security-card-icon {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 9px;
color: #a99cff;
background: var(--accent-soft);
}
.security-card-icon.twofa-icon {
color: #e5b05e;
background: rgba(229,176,94,.1);
}
.security-card-icon.passkey-icon {
color: #6d9fe6;
background: rgba(109,159,230,.1);
}
.security-card h3 {
margin: 0;
font-size: 13px;
color: #e8eaf0;
}
.security-value {
font-size: 13px;
font-weight: 600;
color: #e8eaf0;
}
.policy-text {
font-size: 11px;
font-family: monospace;
word-break: break-word;
}
.security-desc {
margin: 0;
font-size: 10px;
color: #7e8799;
line-height: 1.5;
}
.security-detail-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.security-detail-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.security-detail-label {
font-size: 10px;
color: #8991a1;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.security-detail-value {
font-size: 10px;
color: #e8eaf0;
font-weight: 500;
text-align: right;
word-break: break-word;
}
code.security-detail-value {
font-family: monospace;
background: rgba(139,124,246,.06);
padding: 1px 5px;
border-radius: 4px;
font-size: 9px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-bool {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 6px;
}
.status-bool.enabled {
background: rgba(81,212,154,.1);
color: #51d49a;
}
.status-bool.disabled {
background: rgba(225,110,117,.08);
color: #e16e75;
}
.security-status-row {
display: flex;
align-items: center;
gap: 6px;
}
.check-icon {
color: #51d49a;
}
.x-icon {
color: #e16e75;
}
.enabled-text {
color: #51d49a;
}
.disabled-text {
color: #e16e75;
}
.memory-status {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 48px;
color: #7e8799;
font-size: 12px;
}
.memory-status.error {
color: #e16e75;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 700px) {
.security-grid {
grid-template-columns: 1fr;
}
}
</style>
+276
View File
@@ -0,0 +1,276 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Save, Lock, User } from '@lucide/vue'
import { useAuthStore } from '../stores/auth'
import { apiFetch } from '../services/api'
const auth = useAuthStore()
const editingName = ref(false)
const displayName = ref(auth.user?.displayName ?? '')
const savingName = ref(false)
const nameError = ref('')
const nameSuccess = ref(false)
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const changingPassword = ref(false)
const passwordError = ref('')
const passwordSuccess = ref(false)
async function saveDisplayName() {
nameError.value = ''
nameSuccess.value = false
const trimmed = displayName.value.trim()
if (!trimmed) {
nameError.value = 'Display name cannot be empty.'
return
}
savingName.value = true
try {
const response = await apiFetch('/api/v1/auth/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: trimmed }),
})
if (!response.ok) throw new Error('Failed to update profile')
const result = await response.json()
if (auth.user) auth.user.displayName = result.displayName
nameSuccess.value = true
editingName.value = false
} catch (e) {
nameError.value = e instanceof Error ? e.message : 'Failed to update name'
} finally {
savingName.value = false
}
}
async function changePassword() {
passwordError.value = ''
passwordSuccess.value = false
if (!currentPassword.value) {
passwordError.value = 'Current password is required.'
return
}
if (!newPassword.value || newPassword.value.length < 10) {
passwordError.value = 'New password must be at least 10 characters.'
return
}
if (newPassword.value !== confirmPassword.value) {
passwordError.value = 'Passwords do not match.'
return
}
changingPassword.value = true
try {
const response = await apiFetch('/api/v1/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPassword: currentPassword.value,
newPassword: newPassword.value,
}),
})
if (!response.ok) {
const detail = await response.json().catch(() => ({}))
throw new Error(detail.detail || 'Failed to change password')
}
passwordSuccess.value = true
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
} catch (e) {
passwordError.value = e instanceof Error ? e.message : 'Failed to change password'
} finally {
changingPassword.value = false
}
}
</script>
<template>
<div class="settings-page">
<div class="settings-card">
<div class="settings-header">
<User :size="20" />
<h2>Profile</h2>
</div>
<div class="setting-row">
<label>Email</label>
<span class="setting-value">{{ auth.user?.email }}</span>
</div>
<div class="setting-row">
<label>Display Name</label>
<div v-if="editingName" class="setting-edit">
<input v-model="displayName" maxlength="100" class="setting-input" />
<div class="setting-edit-actions">
<button class="btn-primary btn-sm" @click="saveDisplayName" :disabled="savingName">
<Save :size="13" /> {{ savingName ? 'Saving...' : 'Save' }}
</button>
<button class="btn-ghost btn-sm" @click="editingName = false">Cancel</button>
</div>
<p v-if="nameError" class="setting-error">{{ nameError }}</p>
<p v-else-if="nameSuccess" class="setting-success">Display name updated.</p>
</div>
<div v-else class="setting-value-row">
<span class="setting-value">{{ auth.user?.displayName }}</span>
<button class="btn-ghost btn-sm" @click="editingName = true">Edit</button>
</div>
</div>
<div class="setting-row">
<label>Role</label>
<span class="setting-value badge">{{ auth.user?.role }}</span>
</div>
</div>
<div class="settings-card">
<div class="settings-header">
<Lock :size="20" />
<h2>Change Password</h2>
</div>
<form class="password-form" @submit.prevent="changePassword">
<div class="setting-row">
<label>Current Password</label>
<input v-model="currentPassword" type="password" class="setting-input" autocomplete="current-password" />
</div>
<div class="setting-row">
<label>New Password</label>
<input v-model="newPassword" type="password" class="setting-input" autocomplete="new-password" minlength="10" />
</div>
<div class="setting-row">
<label>Confirm Password</label>
<input v-model="confirmPassword" type="password" class="setting-input" autocomplete="new-password" />
</div>
<p v-if="passwordError" class="setting-error">{{ passwordError }}</p>
<p v-else-if="passwordSuccess" class="setting-success">Password changed successfully.</p>
<button type="submit" class="btn-primary" :disabled="changingPassword">
<Lock :size="14" /> {{ changingPassword ? 'Changing...' : 'Change Password' }}
</button>
</form>
</div>
</div>
</template>
<style scoped>
.settings-page {
max-width: 600px;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.settings-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.settings-header {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.25rem;
color: var(--text-primary);
}
.settings-header h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
}
.setting-row {
margin-bottom: 1rem;
}
.setting-row label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.3rem;
}
.setting-value {
font-size: 0.95rem;
color: var(--text-primary);
}
.setting-value-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.setting-edit {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.setting-edit-actions {
display: flex;
gap: 0.5rem;
}
.setting-input {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.95rem;
color: var(--text-primary);
}
.password-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 1rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
width: fit-content;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm {
padding: 0.35rem 0.65rem;
font-size: 0.8rem;
}
.btn-ghost {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
}
.setting-error {
color: rgb(239, 68, 68);
font-size: 0.85rem;
margin: 0.35rem 0 0;
}
.setting-success {
color: rgb(34, 197, 94);
font-size: 0.85rem;
margin: 0.35rem 0 0;
}
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
background: var(--accent-soft);
color: var(--accent);
border-radius: 4px;
font-size: 0.8rem;
text-transform: uppercase;
}
</style>
+299
View File
@@ -0,0 +1,299 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import AgentCard from '../components/team/AgentCard.vue'
const router = useRouter()
interface AgentCardData {
id: string
name: string
role: string
description: string
tags: string[]
color: string
icon: string
hero?: boolean
}
const agents: AgentCardData[] = [
{
id: 'iris',
name: 'Iris',
role: 'Chief of Staff',
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
tags: ['Orchestration', 'Delegation', 'Approval'],
color: '#8b7cf6',
icon: 'bot',
hero: true,
},
{
id: 'programmer',
name: 'Programmer',
role: 'Lead Developer',
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
tags: ['coding', 'development', 'builds'],
color: '#4d8cf6',
icon: 'code',
},
{
id: 'architekt',
name: 'Architekt',
role: 'Infrastructure Engineer',
description: 'Verantwortlich für Docker, Nginx, Deployment und VPS-Infrastruktur.',
tags: ['infrastructure', 'deployment', 'docker'],
color: '#4da8f6',
icon: 'server',
},
{
id: 'reviewer',
name: 'Reviewer',
role: 'Code QA',
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
tags: ['Quality Assurance', 'Security', 'Code Review'],
color: '#f6a84d',
icon: 'shield',
},
{
id: 'researcher',
name: 'Researcher',
role: 'Research Analyst',
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
tags: ['Research', 'Analysis', 'Fact-Checking'],
color: '#8b4df6',
icon: 'search',
},
{
id: 'executor',
name: 'Executor',
role: 'Host Executor',
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
tags: ['Execution', 'Docker', 'VPS'],
color: '#4df6d4',
icon: 'terminal',
},
]
const heroAgent = agents.find(a => a.hero)!
const operationAgents = agents.filter(a => !a.hero && ['programmer', 'architekt'].includes(a.id))
const specialistAgents = agents.filter(a => ['reviewer', 'researcher', 'executor'].includes(a.id))
function goToAgent(id: string) {
router.push(`/agents/${id}`)
}
</script>
<template>
<div class="team-page">
<!-- Quote Pill -->
<div class="quote-pill">
<span class="quote-text">"An autonomous organization of AI agents that does work for me and produces value 24/7"</span>
</div>
<!-- Header -->
<div class="team-header">
<h1 class="team-title">Meet the Team</h1>
<p class="team-subtitle">{{ agents.length }} AI agents, each with a real role and a real personality.</p>
<p class="team-description">Mission Control orchestriert ein Team spezialisierter Agenten jeder mit eigener Identität, eigenem Workspace und klaren Verantwortlichkeiten.</p>
</div>
<!-- Hero Card -->
<div class="hero-section">
<AgentCard
v-bind="heroAgent"
@click="goToAgent"
/>
</div>
<!-- Section Divider -->
<div class="section-divider">
<div class="divider-line"></div>
<span class="divider-label">OPERATIONS</span>
<div class="divider-line"></div>
</div>
<!-- Operations Row -->
<div class="ops-row">
<AgentCard
v-for="agent in operationAgents"
:key="agent.id"
v-bind="agent"
@click="goToAgent"
/>
</div>
<!-- Connector Labels -->
<div class="connector-row">
<div class="connector-left">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M6 0L6 10M6 10L2 6M6 10L10 6" stroke="#51d49a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>INPUT SIGNAL</span>
</div>
<div class="connector-rail">
<div class="rail-line"></div>
<div class="rail-dot"></div>
<div class="rail-line"></div>
</div>
<div class="connector-right">
<span>OUTPUT ACTION</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M6 12L6 2M6 2L2 6M6 2L10 6" stroke="#4d8cf6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<!-- Specialists Row -->
<div class="specialists-row">
<AgentCard
v-for="agent in specialistAgents"
:key="agent.id"
v-bind="agent"
@click="goToAgent"
/>
</div>
</div>
</template>
<style scoped>
.team-page {
max-width: 820px;
margin: 0 auto;
padding-bottom: 40px;
}
.quote-pill {
background: var(--panel);
border: 1px solid rgba(139, 124, 246, 0.25);
border-radius: 14px;
padding: 14px 22px;
margin-bottom: 24px;
box-shadow: 0 0 18px rgba(139, 124, 246, 0.06), inset 0 0 18px rgba(139, 124, 246, 0.03);
text-align: center;
}
.quote-text {
font-style: italic;
font-size: 12px;
color: #9ea5b3;
line-height: 1.5;
}
.team-header {
text-align: center;
margin-bottom: 28px;
}
.team-title {
font-size: 26px;
font-weight: 600;
color: #e8eaf0;
margin: 0 0 6px;
}
.team-subtitle {
font-size: 12px;
color: #7e8799;
margin: 0 0 4px;
}
.team-description {
font-size: 10.5px;
color: #6b7385;
margin: 0;
max-width: 560px;
margin-left: auto;
margin-right: auto;
}
.section-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 32px 0 24px;
position: relative;
}
.divider-line {
flex: 1;
height: 1px;
background: var(--line);
}
.divider-label {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.1em;
color: #6b7385;
white-space: nowrap;
}
.ops-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.connector-row {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
padding: 0 6px;
}
.connector-left {
display: flex;
align-items: center;
gap: 5px;
font-size: 8.5px;
font-weight: 700;
color: #51d49a;
letter-spacing: 0.08em;
white-space: nowrap;
}
.connector-right {
display: flex;
align-items: center;
gap: 5px;
font-size: 8.5px;
font-weight: 700;
color: #4d8cf6;
letter-spacing: 0.08em;
white-space: nowrap;
}
.connector-rail {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
}
.rail-line {
flex: 1;
height: 1px;
background: var(--line);
}
.rail-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #5b5286;
flex-shrink: 0;
}
.specialists-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
@media (max-width: 720px) {
.ops-row {
grid-template-columns: 1fr;
}
.specialists-row {
grid-template-columns: 1fr;
}
.team-title {
font-size: 22px;
}
}
@media (min-width: 721px) and (max-width: 820px) {
.specialists-row {
grid-template-columns: 1fr 1fr;
}
}
</style>