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>
|
||||
@@ -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">→</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user