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,13 @@
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile=false
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#080a0f" />
|
||||
<title>Nexus | Noveria Operations</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://api:8080/health;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "nexus-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"@lucide/vue": "1.17.0",
|
||||
"pinia": "^3.0.3",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"vue": "^3.5.16",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.29",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"typescript": "~5.7.3",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.1.3",
|
||||
"vue-tsc": "^2.2.10"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.1"
|
||||
}
|
||||
|
||||
Generated
+1442
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Activity } from '@lucide/vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import { useOperationsStore } from './stores/operations'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import AppSidebar from './components/layout/AppSidebar.vue'
|
||||
import AppHeader from './components/layout/AppHeader.vue'
|
||||
import ModuleView from './components/ModuleView.vue'
|
||||
|
||||
const store = useOperationsStore()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeView = computed(() => {
|
||||
if (route.name === 'Settings') return 'Settings'
|
||||
if (route.name === 'ProjectDetail') return 'ProjectDetail'
|
||||
return String(route.name ?? 'Dashboard')
|
||||
})
|
||||
|
||||
const routePaths: Record<string, string> = {
|
||||
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Team: '/team', Security: '/security',
|
||||
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
|
||||
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Settings: '/settings',
|
||||
}
|
||||
|
||||
const navigate = (label: string) => {
|
||||
mobileNavOpen.value = false
|
||||
return router.push(routePaths[label] ?? '/dashboard')
|
||||
}
|
||||
const mobileNavOpen = ref(false)
|
||||
|
||||
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Team', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents'].includes(activeView.value))
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAuthenticated) store.refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView v-if="route.name === 'Login'" />
|
||||
<div v-else class="shell">
|
||||
<AppSidebar
|
||||
:active-view="activeView"
|
||||
:mobile-nav-open="mobileNavOpen"
|
||||
:queued-tasks="store.snapshot.metrics.queuedTasks"
|
||||
:incidents="store.snapshot.metrics.incidents"
|
||||
@navigate="navigate"
|
||||
/>
|
||||
|
||||
<main>
|
||||
<AppHeader
|
||||
:connected="store.connected"
|
||||
@toggle-mobile-nav="mobileNavOpen = !mobileNavOpen"
|
||||
/>
|
||||
|
||||
<section class="content">
|
||||
<RouterView v-if="standaloneViews" />
|
||||
|
||||
<template v-else>
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<span class="eyebrow">MISSION CONTROL</span>
|
||||
<h1>{{ activeView }}</h1>
|
||||
<p>System overview and operational intelligence across Noveria.</p>
|
||||
</div>
|
||||
<button class="refresh" @click="store.refresh()">
|
||||
<Activity :size="15" :class="{ spin: store.loading }" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ModuleView
|
||||
:view="activeView"
|
||||
:snapshot="store.snapshot"
|
||||
:routing="store.routing"
|
||||
@create-project="store.createProject"
|
||||
@create-task="store.createTask"
|
||||
@update-task-state="store.updateTaskState"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:root {
|
||||
--bg: #0b0d13;
|
||||
--panel: #11141b;
|
||||
--line: #1f2330;
|
||||
--accent: #7b6ef2;
|
||||
--accent-soft: rgba(123,110,242,.08);
|
||||
--text: #e8eaf0;
|
||||
--text-dim: #6f7889;
|
||||
--green: #27ae60;
|
||||
--red: #e74c3c;
|
||||
--yellow: #f1c40f;
|
||||
--orange: #e67e22;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { font-size: 15px; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-heading h1 { margin: 0; font-size: 18px; }
|
||||
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--text-dim); }
|
||||
.eyebrow {
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.refresh:hover { background: var(--accent-soft); color: #d8dbe3; }
|
||||
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.kanban { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,630 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, ChevronLeft, ChevronRight, Edit2, Save, X, Trash2 } from '@lucide/vue'
|
||||
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
|
||||
import { TASK_STATES } from '../types'
|
||||
import { apiFetch } from '../services/api'
|
||||
import { useOperationsStore } from '../stores/operations'
|
||||
|
||||
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
|
||||
const emit = defineEmits<{
|
||||
createProject: [name: string]
|
||||
createTask: [title: string, priority: string]
|
||||
updateTaskState: [id: string, state: string]
|
||||
}>()
|
||||
const store = useOperationsStore()
|
||||
const agents = ref<AgentInfo[]>([])
|
||||
const agentsLoading = ref(false)
|
||||
|
||||
async function loadAgents() {
|
||||
if (agentsLoading.value) return
|
||||
agentsLoading.value = true
|
||||
agents.value = await store.fetchAgents()
|
||||
agentsLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.view === 'Agents') loadAgents()
|
||||
})
|
||||
|
||||
watch(() => props.view, (v) => {
|
||||
if (v === 'Agents') loadAgents()
|
||||
})
|
||||
|
||||
const newProject = ref('')
|
||||
const newTask = ref('')
|
||||
const message = ref('')
|
||||
const chatMessages = ref<Array<{ role: 'owner' | 'iris' | 'error'; content: string }>>([])
|
||||
const chatPending = ref(false)
|
||||
const conversationId = ref(localStorage.getItem('nexus-conversation-id') ?? crypto.randomUUID())
|
||||
localStorage.setItem('nexus-conversation-id', conversationId.value)
|
||||
|
||||
// Task editing state
|
||||
const editingTaskId = ref<string | null>(null)
|
||||
|
||||
// Task approval / rejection state
|
||||
const approvingTaskId = ref<string | null>(null)
|
||||
const taskActionError = ref('')
|
||||
|
||||
async function handleApproveTask(id: string) {
|
||||
approvingTaskId.value = id
|
||||
taskActionError.value = ''
|
||||
try {
|
||||
await store.approveTask(id)
|
||||
} catch (e) {
|
||||
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
|
||||
} finally {
|
||||
approvingTaskId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectTask(id: string) {
|
||||
approvingTaskId.value = id
|
||||
taskActionError.value = ''
|
||||
try {
|
||||
await store.rejectTask(id)
|
||||
} catch (e) {
|
||||
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
|
||||
} finally {
|
||||
approvingTaskId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Task deletion state
|
||||
const deletingTaskId = ref<string | null>(null)
|
||||
const deleteError = ref('')
|
||||
|
||||
async function confirmDeleteTask(id: string) {
|
||||
deleteError.value = ''
|
||||
try {
|
||||
await store.deleteTask(id)
|
||||
deletingTaskId.value = null
|
||||
} catch (e) {
|
||||
deleteError.value = e instanceof Error ? e.message : 'Failed to delete task'
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDeleteTask() {
|
||||
deletingTaskId.value = null
|
||||
deleteError.value = ''
|
||||
}
|
||||
const editTaskTitle = ref('')
|
||||
const editTaskPriority = ref('')
|
||||
const editTaskProjectId = ref<string | null>(null)
|
||||
|
||||
// Activity filtering and pagination
|
||||
const activityTypeFilter = ref('')
|
||||
const activitySort = ref('newest')
|
||||
const activityPage = ref(1)
|
||||
const activityPageSize = 20
|
||||
const activityTotalPages = ref(1)
|
||||
const activityTotalCount = ref(0)
|
||||
|
||||
const columns = computed(() =>
|
||||
TASK_STATES.map(state => ({ name: state, items: props.snapshot.tasks.filter(x => x.state === state) })))
|
||||
|
||||
const availableTypes = computed(() => {
|
||||
const types = new Set(props.snapshot.activity.map(e => e.type))
|
||||
return Array.from(types)
|
||||
})
|
||||
|
||||
const filteredActivity = computed(() => {
|
||||
let items = [...props.snapshot.activity]
|
||||
if (activityTypeFilter.value) {
|
||||
items = items.filter(e => e.type === activityTypeFilter.value)
|
||||
}
|
||||
if (activitySort.value === 'oldest') {
|
||||
items.reverse()
|
||||
}
|
||||
const total = items.length
|
||||
activityTotalCount.value = total
|
||||
activityTotalPages.value = Math.max(1, Math.ceil(total / activityPageSize))
|
||||
const start = (activityPage.value - 1) * activityPageSize
|
||||
return items.slice(start, start + activityPageSize)
|
||||
})
|
||||
|
||||
watch(activityTypeFilter, () => { activityPage.value = 1 })
|
||||
watch(activitySort, () => { activityPage.value = 1 })
|
||||
|
||||
function startEditTask(task: { id: string; title: string; priority: string; projectId?: string | null }) {
|
||||
editingTaskId.value = task.id
|
||||
editTaskTitle.value = task.title
|
||||
editTaskPriority.value = task.priority
|
||||
editTaskProjectId.value = task.projectId ?? null
|
||||
}
|
||||
|
||||
async function saveEditTask(id: string) {
|
||||
try {
|
||||
await store.updateTask(id, {
|
||||
title: editTaskTitle.value.trim() || undefined,
|
||||
priority: editTaskPriority.value || undefined,
|
||||
projectId: editTaskProjectId.value || undefined,
|
||||
})
|
||||
editingTaskId.value = null
|
||||
} catch (e) {
|
||||
console.error('Failed to update task', e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditTask() {
|
||||
editingTaskId.value = null
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const value = message.value.trim()
|
||||
if (!value || chatPending.value) return
|
||||
chatMessages.value.push({ role: 'owner', content: value })
|
||||
message.value = ''
|
||||
chatPending.value = true
|
||||
try {
|
||||
const response = await apiFetch('/api/v1/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: value, conversationId: conversationId.value, agentId: 'iris' }),
|
||||
})
|
||||
const payload = await response.json()
|
||||
if (!response.ok) throw new Error(payload.detail ?? 'Iris is currently unavailable.')
|
||||
conversationId.value = payload.conversationId
|
||||
localStorage.setItem('nexus-conversation-id', payload.conversationId)
|
||||
chatMessages.value.push({ role: 'iris', content: payload.content })
|
||||
} catch (error) {
|
||||
chatMessages.value.push({ role: 'error', content: error instanceof Error ? error.message : 'Iris is currently unavailable.' })
|
||||
} finally {
|
||||
chatPending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form v-if="view === 'Projects'" class="quick-create" @submit.prevent="newProject.trim() && (emit('createProject', newProject.trim()), newProject = '')"><input v-model="newProject" placeholder="New project name" /><button>Create project</button></form>
|
||||
<div v-if="view === 'Projects'" class="module-grid">
|
||||
<article v-for="project in snapshot.projects" :key="project.id" class="module-card project-card" @click="$router.push(`/projects/${project.id}`)">
|
||||
<div class="module-card-head"><span class="project-letter">{{ project.name[0] }}</span><span class="badge positive">{{ project.status }}</span></div>
|
||||
<h3>{{ project.name }}</h3><p>Operational workspace managed through Nexus.</p>
|
||||
<div class="progress"><i :style="{ width: `${project.progress}%` }"></i></div>
|
||||
<footer><span>Progress</span><strong>{{ project.progress }}%</strong></footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
|
||||
<div v-if="view === 'Task Board'" class="kanban">
|
||||
<section v-for="column in columns" :key="column.name" class="kanban-column">
|
||||
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
|
||||
<article v-for="task in column.items" :key="task.id" class="task-card">
|
||||
<template v-if="editingTaskId === task.id">
|
||||
<input v-model="editTaskTitle" class="task-edit-input" placeholder="Task title" maxlength="240" />
|
||||
<div class="task-edit-row">
|
||||
<select v-model="editTaskPriority">
|
||||
<option value="Critical">Critical</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Normal">Normal</option>
|
||||
<option value="Low">Low</option>
|
||||
</select>
|
||||
<select v-model="editTaskProjectId">
|
||||
<option :value="null">No project</option>
|
||||
<option v-for="p in snapshot.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="task-edit-actions">
|
||||
<button class="task-edit-save" @click="saveEditTask(task.id)"><Save :size="13" /> Save</button>
|
||||
<button class="task-edit-cancel" @click="cancelEditTask"><X :size="13" /> Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="task-card-head">
|
||||
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
|
||||
<div class="task-card-actions">
|
||||
<template v-if="task.state === 'In progress'">
|
||||
<button
|
||||
class="task-approve-btn"
|
||||
title="Approve"
|
||||
:disabled="approvingTaskId === task.id"
|
||||
@click="handleApproveTask(task.id)"
|
||||
><CheckCircle2 :size="13" /></button>
|
||||
<button
|
||||
class="task-reject-btn"
|
||||
title="Reject"
|
||||
:disabled="approvingTaskId === task.id"
|
||||
@click="handleRejectTask(task.id)"
|
||||
><X :size="13" /></button>
|
||||
</template>
|
||||
<button class="task-edit-btn" @click="startEditTask(task)" title="Edit task"><Edit2 :size="12" /></button>
|
||||
<button
|
||||
v-if="task.state === 'Done' || task.state === 'Backlog'"
|
||||
class="task-delete-btn"
|
||||
title="Delete task"
|
||||
@click="deletingTaskId = task.id; deleteError = ''"
|
||||
><Trash2 :size="12" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<h3>{{ task.title }}</h3>
|
||||
<select :value="task.state" @change="emit('updateTaskState', task.id, ($event.target as HTMLSelectElement).value)">
|
||||
<option v-for="state in TASK_STATES" :key="state" :value="state">{{ state }}</option>
|
||||
</select>
|
||||
<footer><Clock3 :size="13" /> {{ new Date(task.updatedAt).toLocaleString() }}</footer>
|
||||
</template>
|
||||
</article>
|
||||
<div v-if="!column.items.length" class="empty-state">No tasks</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Agents'" class="module-grid">
|
||||
<div v-if="agentsLoading" class="loading-agents">Loading agents…</div>
|
||||
<article v-for="agent in agents" :key="agent.id" class="module-card agent-card">
|
||||
<div class="agent-avatar" :class="agent.role === 'orchestrator' ? 'violet' : ''">
|
||||
<Bot v-if="agent.role === 'orchestrator'" :size="22" />
|
||||
<Zap v-else :size="22" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="kicker">{{ agent.role.toUpperCase() }}</span>
|
||||
<h3>{{ agent.name }}</h3>
|
||||
<p>{{ agent.description || agent.model }}</p>
|
||||
</div>
|
||||
<div class="agent-status-group">
|
||||
<span v-if="agent.model" class="agent-model-tag">{{ agent.model.replace(/^[^/]*\//, '') }}</span>
|
||||
<span :class="['badge', agent.status === 'Online' ? 'positive' : agent.status === 'Degraded' ? 'warning' : 'negative']">{{ agent.status }}</span>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="!agentsLoading && !agents.length" class="empty-state">No agents available</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Models'" class="module-list panel">
|
||||
<div v-for="model in routing" :key="model.model" class="model-detail">
|
||||
<div class="route-rank">0{{ model.priority }}</div><div><span class="kicker">{{ model.purpose }}</span><h3>{{ model.model }}</h3><p>{{ model.provider }} · {{ model.detail }}</p></div><span :class="['badge', model.status === 'Online' ? 'positive' : 'warning']">{{ model.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Activity'" class="activity-panel panel">
|
||||
<div class="activity-filters">
|
||||
<div class="filter-group">
|
||||
<label>Type</label>
|
||||
<select v-model="activityTypeFilter">
|
||||
<option value="">All types</option>
|
||||
<option v-for="type in availableTypes" :key="type" :value="type">{{ type }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Sort</label>
|
||||
<select v-model="activitySort">
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<article v-for="event in filteredActivity" :key="event.message + event.at">
|
||||
<div :class="['timeline-icon', event.type]">
|
||||
<CheckCircle2 v-if="event.type !== 'security'" :size="15" />
|
||||
<ShieldAlert v-else :size="15" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="kicker">{{ event.type }}</span>
|
||||
<h3>{{ event.message }}</h3>
|
||||
<p>{{ new Date(event.at).toLocaleString() }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="activityTotalPages > 1" class="activity-pagination">
|
||||
<button :disabled="activityPage <= 1" @click="activityPage--"><ChevronLeft :size="14" /></button>
|
||||
<span>{{ activityPage }} / {{ activityTotalPages }}</span>
|
||||
<button :disabled="activityPage >= activityTotalPages" @click="activityPage++"><ChevronRight :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Settings'" class="settings-redirect">
|
||||
<p>Use the <router-link to="/settings">full Settings page</router-link> for profile management and password changes.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Mobile Chat'" class="chat-shell panel">
|
||||
<header><div class="agent-avatar"><MessageSquareText :size="20" /></div><div><h3>Iris Mobile</h3><p>Secure owner operations channel</p></div><span class="badge warning">Preview</span></header>
|
||||
<div class="messages"><div class="message iris"><strong>Iris</strong><p>Nexus is online. Messages are routed through the OpenClaw runtime.</p></div><div v-for="(item, index) in chatMessages" :key="index" :class="['message', item.role]"><strong>{{ item.role === 'owner' ? 'Owner' : item.role === 'iris' ? 'Iris' : 'Runtime' }}</strong><p>{{ item.content }}</p></div><div v-if="chatPending" class="message iris pending"><strong>Iris</strong><p>Working...</p></div></div>
|
||||
<form @submit.prevent="sendMessage"><input v-model="message" :disabled="chatPending" placeholder="Ask for status or create a task..." /><button :disabled="chatPending"><Send :size="15" /></button></form>
|
||||
</div>
|
||||
|
||||
<!-- Task deletion confirmation dialog -->
|
||||
<Teleport to="body">
|
||||
<div v-if="deletingTaskId" class="delete-overlay" @click.self="cancelDeleteTask">
|
||||
<div class="delete-dialog">
|
||||
<h3>Delete Task?</h3>
|
||||
<p>This action cannot be undone. The task will be permanently removed.</p>
|
||||
<p v-if="deleteError" class="delete-error">{{ deleteError }}</p>
|
||||
<div class="delete-actions">
|
||||
<button class="delete-cancel" @click="cancelDeleteTask">Cancel</button>
|
||||
<button class="delete-confirm" @click="confirmDeleteTask(deletingTaskId)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.task-card-actions {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
align-items: center;
|
||||
}
|
||||
.task-edit-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.task-card:hover .task-edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.task-edit-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.task-delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.task-card:hover .task-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.task-delete-btn:hover {
|
||||
color: var(--danger, #e74c3c);
|
||||
}
|
||||
.task-edit-input {
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.task-edit-row {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.task-edit-row select {
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.4rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.task-edit-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.task-edit-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.task-edit-cancel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.activity-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.activity-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.filter-group label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.filter-group select {
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.activity-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.activity-pagination button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.activity-pagination button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.activity-pagination span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.project-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.project-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.settings-redirect {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.settings-redirect a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.settings-redirect a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Task deletion confirmation */
|
||||
.delete-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.delete-dialog {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
max-width: 380px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
}
|
||||
.delete-dialog h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.delete-dialog p {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.delete-error {
|
||||
color: var(--danger, #e74c3c) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.delete-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.delete-cancel {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.delete-confirm {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--danger, #e74c3c);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.delete-confirm:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Agent card enhancements */
|
||||
.agent-status-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.agent-model-tag {
|
||||
font-size: 0.7rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.loading-agents {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Task approve/reject buttons */
|
||||
.task-approve-btn,
|
||||
.task-reject-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.task-card:hover .task-approve-btn,
|
||||
.task-card:hover .task-reject-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.task-approve-btn {
|
||||
color: var(--success, #27ae60);
|
||||
}
|
||||
.task-approve-btn:hover {
|
||||
color: var(--success, #27ae60);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
.task-reject-btn {
|
||||
color: var(--warning, #f39c12);
|
||||
}
|
||||
.task-reject-btn:hover {
|
||||
color: var(--danger, #e74c3c);
|
||||
}
|
||||
.task-approve-btn:disabled,
|
||||
.task-reject-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2, Save, Check, AlertCircle } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
fileName: string | null
|
||||
fileSize: string
|
||||
fileModified: string
|
||||
content: string
|
||||
dirty: boolean
|
||||
saving: boolean
|
||||
saveStatus: 'idle' | 'saved' | 'error'
|
||||
saveMessage: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
updateContent: [value: string]
|
||||
save: []
|
||||
}>()
|
||||
|
||||
function onInput(event: Event) {
|
||||
const textarea = event.target as HTMLTextAreaElement
|
||||
// Pass content change up, parent handles dirty detection
|
||||
;(event.target as HTMLTextAreaElement).dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-panel">
|
||||
<!-- File info header -->
|
||||
<div class="editor-header">
|
||||
<div class="editor-file-info">
|
||||
<span class="editor-filename">{{ fileName || '—' }}</span>
|
||||
<span v-if="fileName" class="editor-file-meta">
|
||||
{{ fileSize }}
|
||||
<span class="meta-sep">·</span>
|
||||
{{ fileModified }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Save button & status -->
|
||||
<div class="editor-actions">
|
||||
<span v-if="saveStatus === 'saved'" class="save-indicator success">
|
||||
<Check :size="14" />
|
||||
{{ saveMessage }}
|
||||
</span>
|
||||
<span v-if="saveStatus === 'error'" class="save-indicator error">
|
||||
<AlertCircle :size="14" />
|
||||
{{ saveMessage }}
|
||||
</span>
|
||||
<button
|
||||
class="save-btn"
|
||||
:class="{ dirty, saving }"
|
||||
:disabled="!dirty || saving"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
<Loader2 v-if="saving" :size="14" class="spin" />
|
||||
<Save v-else :size="14" />
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text editor -->
|
||||
<textarea
|
||||
class="config-editor"
|
||||
:value="content"
|
||||
@input="$emit('updateContent', ($event.target as HTMLTextAreaElement).value)"
|
||||
spellcheck="false"
|
||||
wrap="off"
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-panel {
|
||||
border: 1px solid var(--line, #1e2030);
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
overflow: hidden;
|
||||
background: var(--panel, #13141f);
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255,255,255,.02);
|
||||
border-bottom: 1px solid var(--line, #1e2030);
|
||||
gap: 12px;
|
||||
}
|
||||
.editor-file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.editor-filename {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #d0d4dd;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.editor-file-meta {
|
||||
font-size: 10px;
|
||||
color: #6b7385;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta-sep {
|
||||
margin: 0 4px;
|
||||
color: #3d4152;
|
||||
}
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.save-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.save-indicator.success {
|
||||
color: #51d49a;
|
||||
}
|
||||
.save-indicator.error {
|
||||
color: #e16e75;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--line, #1e2030);
|
||||
border-radius: 7px;
|
||||
background: rgba(139,124,246,.08);
|
||||
color: #8b7cf6;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
.save-btn:hover:not(:disabled) {
|
||||
background: rgba(139,124,246,.14);
|
||||
border-color: #443d7c;
|
||||
}
|
||||
.save-btn.dirty {
|
||||
background: rgba(139,124,246,.18);
|
||||
border-color: #5c4ed6;
|
||||
color: #a99cff;
|
||||
}
|
||||
.save-btn.saving {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
.save-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.config-editor {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
background: #0d0e17;
|
||||
color: #c8cbe0;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
tab-size: 2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.config-editor:focus {
|
||||
background: #0f101b;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.editor-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.editor-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
tabs: string[]
|
||||
activeTab: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
switchTab: [index: number]
|
||||
}>()
|
||||
|
||||
function tabLabel(tab: string): string {
|
||||
return tab.replace('.md', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="config-tabs">
|
||||
<button
|
||||
v-for="(tab, idx) in tabs"
|
||||
:key="tab"
|
||||
class="config-tab"
|
||||
:class="{ active: activeTab === idx }"
|
||||
@click="$emit('switchTab', idx)"
|
||||
>
|
||||
{{ tabLabel(tab) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.config-tabs {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: var(--line, #1e2030);
|
||||
border-radius: 10px 10px 0 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line, #1e2030);
|
||||
border-bottom: none;
|
||||
}
|
||||
.config-tab {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
background: var(--panel, #13141f);
|
||||
border: none;
|
||||
color: #6b7385;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
letter-spacing: 0.04em;
|
||||
font-family: inherit;
|
||||
}
|
||||
.config-tab:hover {
|
||||
background: rgba(139,124,246,.06);
|
||||
color: #a0a8b8;
|
||||
}
|
||||
.config-tab.active {
|
||||
background: rgba(139,124,246,.1);
|
||||
color: #c8cbe0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.config-tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.config-tab {
|
||||
flex: 1 0 auto;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Clock } from '@lucide/vue'
|
||||
|
||||
type InitiativeStatus = 'healthy' | 'attention' | 'blocked' | 'paused' | 'completed'
|
||||
|
||||
interface Initiative {
|
||||
title: string
|
||||
progress: number
|
||||
openTasks: number
|
||||
blockers: number
|
||||
status: InitiativeStatus
|
||||
lastActivity: string
|
||||
}
|
||||
|
||||
const initiatives = ref<Initiative[]>([
|
||||
{ title: 'OpenClaw Companion', progress: 55, openTasks: 7, blockers: 2, status: 'healthy', lastActivity: 'vor 8 Minuten' },
|
||||
{ title: '2D Idle Game', progress: 42, openTasks: 4, blockers: 0, status: 'healthy', lastActivity: 'vor 2 Stunden' },
|
||||
{ title: 'Deutsch B2', progress: 73, openTasks: 3, blockers: 0, status: 'attention', lastActivity: 'vor 1 Stunde' },
|
||||
{ title: 'Nexus Dashboard', progress: 60, openTasks: 3, blockers: 0, status: 'healthy', lastActivity: 'vor 5 Minuten' },
|
||||
])
|
||||
|
||||
const statusMeta: Record<InitiativeStatus, { label: string; color: string; bg: string }> = {
|
||||
healthy: { label: 'Healthy', color: '#22c55e', bg: 'rgba(34,197,94,0.1)' },
|
||||
attention: { label: 'Needs Attention', color: '#eab308', bg: 'rgba(234,179,8,0.1)' },
|
||||
blocked: { label: 'Blocked', color: '#ef4444', bg: 'rgba(239,68,68,0.1)' },
|
||||
paused: { label: 'Paused', color: '#6b7280', bg: 'rgba(107,114,128,0.1)' },
|
||||
completed: { label: 'Completed', color: '#3b82f6', bg: 'rgba(59,130,246,0.1)' },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="initiatives-section">
|
||||
<h2>Active Initiatives</h2>
|
||||
<div class="initiatives-grid">
|
||||
<div
|
||||
v-for="(init, idx) in initiatives"
|
||||
:key="idx"
|
||||
:class="['initiative-card', 'status-' + init.status]"
|
||||
>
|
||||
<div class="init-head">
|
||||
<h3>{{ init.title }}</h3>
|
||||
<span
|
||||
class="status-badge"
|
||||
:style="{
|
||||
color: statusMeta[init.status].color,
|
||||
background: statusMeta[init.status].bg,
|
||||
}"
|
||||
>
|
||||
{{ statusMeta[init.status].label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: init.progress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="progress-label">{{ init.progress }}%</div>
|
||||
<div class="init-stats">
|
||||
<span>{{ init.openTasks }} offene Aufgaben</span>
|
||||
<span v-if="init.blockers">· {{ init.blockers }} Blocker</span>
|
||||
</div>
|
||||
<div class="init-meta">
|
||||
<Clock :size="11" />
|
||||
<span>Letzte Aktivität {{ init.lastActivity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.initiatives-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.initiatives-section:hover {
|
||||
border-color: rgba(139, 124, 246, 0.18);
|
||||
}
|
||||
.initiatives-section h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.initiatives-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.initiative-card {
|
||||
background: rgba(13, 17, 23, 0.5);
|
||||
border: 1px solid rgba(139, 124, 246, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.initiative-card:hover {
|
||||
border-color: rgba(139, 124, 246, 0.2);
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
.init-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
.init-head h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: rgba(139, 124, 246, 0.1);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #a78bfa, #8b5cf6);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.initiative-card.status-attention .progress-fill {
|
||||
background: linear-gradient(90deg, #eab308, #f59e0b);
|
||||
}
|
||||
.initiative-card.status-blocked .progress-fill {
|
||||
background: linear-gradient(90deg, #ef4444, #dc2626);
|
||||
}
|
||||
.progress-label {
|
||||
font-size: 10px;
|
||||
color: #6b7385;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.init-stats {
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.init-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
}
|
||||
.init-meta svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.initiatives-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.initiatives-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { CheckCircle2, Circle, AlertTriangle } from '@lucide/vue'
|
||||
|
||||
interface AgendaItem {
|
||||
text: string
|
||||
time?: string
|
||||
done?: boolean
|
||||
overdue?: boolean
|
||||
}
|
||||
|
||||
const agendaToday = ref<AgendaItem[]>([
|
||||
{ text: 'Teammeeting', time: '14:00' },
|
||||
{ text: 'Deutsch lernen', time: '18:00' },
|
||||
{ text: 'Steuerunterlagen prüfen' },
|
||||
{ text: 'Dungeon-Balance abschließen' },
|
||||
])
|
||||
|
||||
const agendaTomorrow = ref<AgendaItem[]>([
|
||||
{ text: 'GitHub Issue #23' },
|
||||
{ text: 'Backup überprüfen' },
|
||||
])
|
||||
|
||||
const agendaOverdue = ref<AgendaItem[]>([
|
||||
{ text: 'Hangfire konfigurieren', overdue: true },
|
||||
])
|
||||
|
||||
function toggleAgendaItem(item: AgendaItem) {
|
||||
item.done = !item.done
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agenda-panel">
|
||||
<h2>Agenda</h2>
|
||||
|
||||
<div class="agenda-section">
|
||||
<h3>Heute</h3>
|
||||
<div
|
||||
v-for="(item, idx) in agendaToday"
|
||||
:key="'today-' + idx"
|
||||
:class="['agenda-item', { done: item.done }]"
|
||||
@click="toggleAgendaItem(item)"
|
||||
>
|
||||
<button class="agenda-check">
|
||||
<CheckCircle2 v-if="item.done" :size="14" class="checked" />
|
||||
<Circle v-else :size="14" />
|
||||
</button>
|
||||
<span class="agenda-text">{{ item.text }}</span>
|
||||
<span v-if="item.time" class="agenda-time">{{ item.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agenda-section">
|
||||
<h3>Morgen</h3>
|
||||
<div
|
||||
v-for="(item, idx) in agendaTomorrow"
|
||||
:key="'tomorrow-' + idx"
|
||||
:class="['agenda-item', { done: item.done }]"
|
||||
@click="toggleAgendaItem(item)"
|
||||
>
|
||||
<button class="agenda-check">
|
||||
<CheckCircle2 v-if="item.done" :size="14" class="checked" />
|
||||
<Circle v-else :size="14" />
|
||||
</button>
|
||||
<span class="agenda-text">{{ item.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agenda-section">
|
||||
<h3 class="overdue-heading">
|
||||
<AlertTriangle :size="12" />
|
||||
Überfällig
|
||||
</h3>
|
||||
<div
|
||||
v-for="(item, idx) in agendaOverdue"
|
||||
:key="'overdue-' + idx"
|
||||
class="agenda-item overdue"
|
||||
>
|
||||
<button class="agenda-check">
|
||||
<AlertTriangle :size="14" class="overdue-icon" />
|
||||
</button>
|
||||
<span class="agenda-text">{{ item.text }}</span>
|
||||
<span class="agenda-sub">seit 2 Tagen</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agenda-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.agenda-panel:hover {
|
||||
border-color: rgba(139, 124, 246, 0.18);
|
||||
}
|
||||
.agenda-panel h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.agenda-section h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #6b7385;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 4px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid rgba(139, 124, 246, 0.06);
|
||||
}
|
||||
.overdue-heading {
|
||||
color: #ef4444 !important;
|
||||
border-bottom-color: rgba(239, 68, 68, 0.15) !important;
|
||||
}
|
||||
.agenda-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.agenda-item:hover {
|
||||
background: rgba(139, 124, 246, 0.04);
|
||||
}
|
||||
.agenda-item.done {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.agenda-item.done .agenda-text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.agenda-check {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7385;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agenda-check .checked {
|
||||
color: #22c55e;
|
||||
}
|
||||
.overdue .overdue-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
.agenda-text {
|
||||
flex: 1;
|
||||
font-size: 10.5px;
|
||||
color: #7e8799;
|
||||
}
|
||||
.agenda-time {
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agenda-sub {
|
||||
font-size: 8px;
|
||||
color: #ef4444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agenda-item.overdue {
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.agenda-panel {
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Bot, Sparkles, MessageSquareText, ListTodo, Zap, FileText, Send, Lightbulb } from '@lucide/vue'
|
||||
import { useTime } from '../../composables/useTime'
|
||||
|
||||
const { greeting } = useTime()
|
||||
|
||||
const chatInput = ref('')
|
||||
function sendChat() {
|
||||
if (!chatInput.value.trim()) return
|
||||
alert(`[Iris] Received: "${chatInput.value}"`)
|
||||
chatInput.value = ''
|
||||
}
|
||||
|
||||
const meters = {
|
||||
openTasks: 12,
|
||||
blocked: 3,
|
||||
overdue: 2,
|
||||
todayAppointments: 4,
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="iris-panel">
|
||||
<div class="iris-profile">
|
||||
<div class="iris-avatar">
|
||||
<Bot :size="32" />
|
||||
</div>
|
||||
<div class="iris-name-block">
|
||||
<h2>Iris</h2>
|
||||
<span class="iris-role">Chief of Staff</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="iris-greeting">{{ greeting }} Bao.</p>
|
||||
<p class="iris-status">Du hast heute <strong>4 wichtige Punkte.</strong></p>
|
||||
|
||||
<div class="meters">
|
||||
<div class="meter-item">
|
||||
<span class="meter-value">{{ meters.openTasks }}</span>
|
||||
<span class="meter-label">Offene Aufgaben</span>
|
||||
</div>
|
||||
<div class="meter-item">
|
||||
<span class="meter-value meter-blocked">{{ meters.blocked }}</span>
|
||||
<span class="meter-label">Blockiert</span>
|
||||
</div>
|
||||
<div class="meter-item">
|
||||
<span class="meter-value meter-overdue">{{ meters.overdue }}</span>
|
||||
<span class="meter-label">Überfällig</span>
|
||||
</div>
|
||||
<div class="meter-item">
|
||||
<span class="meter-value meter-today">{{ meters.todayAppointments }}</span>
|
||||
<span class="meter-label">Heute</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="suggestions">
|
||||
<h3><Sparkles :size="14" /> Vorschläge</h3>
|
||||
<div class="suggestion-card">
|
||||
<Lightbulb :size="14" class="bulb" />
|
||||
<span>Du solltest zuerst das Dungeon-System abschließen.</span>
|
||||
</div>
|
||||
<div class="suggestion-card">
|
||||
<Lightbulb :size="14" class="bulb" />
|
||||
<span>Die Dokumentation wurde seit 3 Tagen nicht aktualisiert.</span>
|
||||
</div>
|
||||
<div class="suggestion-card">
|
||||
<Lightbulb :size="14" class="bulb" />
|
||||
<span>Das Projekt OpenClaw benötigt Aufmerksamkeit.</span>
|
||||
</div>
|
||||
<div class="suggestion-card">
|
||||
<Lightbulb :size="14" class="bulb" />
|
||||
<span>Deine wöchentliche Zusammenfassung ist bereit.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<button class="qa-btn">
|
||||
<MessageSquareText :size="14" /> Chat öffnen
|
||||
</button>
|
||||
<button class="qa-btn">
|
||||
<ListTodo :size="14" /> Tagesplanung
|
||||
</button>
|
||||
<button class="qa-btn">
|
||||
<Zap :size="14" /> Prioritäten setzen
|
||||
</button>
|
||||
<button class="qa-btn">
|
||||
<FileText :size="14" /> Zusammenfassung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="chat-box">
|
||||
<div class="chat-input-row">
|
||||
<input
|
||||
v-model="chatInput"
|
||||
type="text"
|
||||
placeholder="Frag Iris etwas..."
|
||||
@keyup.enter="sendChat"
|
||||
/>
|
||||
<button class="chat-send" @click="sendChat">
|
||||
<Send :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.iris-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.iris-panel:hover {
|
||||
border-color: rgba(139, 124, 246, 0.18);
|
||||
}
|
||||
|
||||
.iris-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.iris-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: rgba(167, 139, 250, 0.15);
|
||||
color: #a78bfa;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.iris-name-block h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.iris-role {
|
||||
font-size: 10px;
|
||||
color: #a78bfa;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.iris-greeting {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.iris-status {
|
||||
font-size: 11px;
|
||||
color: #7e8799;
|
||||
margin: 0;
|
||||
}
|
||||
.iris-status strong {
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
/* Meters */
|
||||
.meters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.meter-item {
|
||||
background: rgba(139, 124, 246, 0.06);
|
||||
border: 1px solid rgba(139, 124, 246, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.meter-item:hover {
|
||||
border-color: rgba(139, 124, 246, 0.18);
|
||||
background: rgba(139, 124, 246, 0.1);
|
||||
}
|
||||
.meter-value {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.meter-blocked { color: #eab308; }
|
||||
.meter-overdue { color: #ef4444; }
|
||||
.meter-today { color: #3b82f6; }
|
||||
.meter-label {
|
||||
display: block;
|
||||
font-size: 8px;
|
||||
color: #6b7385;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Suggestions */
|
||||
.suggestions h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #a78bfa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.suggestion-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
padding: 7px 8px;
|
||||
margin-bottom: 3px;
|
||||
border-radius: 8px;
|
||||
cursor: default;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.suggestion-card:hover {
|
||||
background: rgba(139, 124, 246, 0.08);
|
||||
}
|
||||
.suggestion-card .bulb {
|
||||
color: #eab308;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.suggestion-card span {
|
||||
font-size: 10.5px;
|
||||
line-height: 1.4;
|
||||
color: #7e8799;
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.qa-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(139, 124, 246, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(139, 124, 246, 0.04);
|
||||
color: #7e8799;
|
||||
font-size: 10.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.qa-btn:hover {
|
||||
background: rgba(139, 124, 246, 0.12);
|
||||
border-color: rgba(139, 124, 246, 0.2);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
/* Chat Box */
|
||||
.chat-box {
|
||||
margin-top: auto;
|
||||
}
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.chat-input-row input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(13, 17, 23, 0.6);
|
||||
color: #e8eaf0;
|
||||
font-size: 10.5px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.chat-input-row input:focus {
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
.chat-input-row input::placeholder {
|
||||
color: #6b7385;
|
||||
}
|
||||
.chat-send {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #a78bfa;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.chat-send:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.iris-panel {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
type FeedStatus = 'running' | 'done' | 'waiting' | 'error' | 'info'
|
||||
|
||||
interface FeedItem {
|
||||
time: string
|
||||
status: FeedStatus
|
||||
text: string
|
||||
label: string
|
||||
date: 'today' | 'yesterday' | 'week'
|
||||
}
|
||||
|
||||
const allFeed: FeedItem[] = [
|
||||
{ time: '09:17', status: 'running', text: 'OpenClaw analysiert Memory-Datenbank.', label: 'Memory', date: 'today' },
|
||||
{ time: '09:19', status: 'done', text: 'Repository Refactoring abgeschlossen.', label: 'Coding', date: 'today' },
|
||||
{ time: '09:21', status: 'done', text: '3 neue Erinnerungen gespeichert.', label: 'Memory', date: 'today' },
|
||||
{ time: '09:25', status: 'done', text: 'Dungeon-Service erfolgreich kompiliert.', label: 'Coding', date: 'today' },
|
||||
{ time: '09:28', status: 'error', text: 'Build fehlgeschlagen — NullReferenceException in EnemyFactory.', label: 'Coding', date: 'today' },
|
||||
{ time: '09:31', status: 'waiting', text: 'Iris hat "Steuerunterlagen" auf Freitag verschoben.', label: 'Personal', date: 'today' },
|
||||
{ time: '10:02', status: 'running', text: 'Programmer arbeitet an TeamView-Redesign.', label: 'Coding', date: 'today' },
|
||||
{ time: '10:15', status: 'done', text: 'AgentDetailView deployed.', label: 'System', date: 'today' },
|
||||
{ time: '10:22', status: 'running', text: 'Architekt prüft Compose-Konfiguration.', label: 'System', date: 'today' },
|
||||
{ time: '10:45', status: 'done', text: 'Reviewer: Code-Review abgeschlossen, keine Findings.', label: 'Agenten', date: 'today' },
|
||||
{ time: '11:00', status: 'running', text: 'Researcher analysiert API-Dokumentation.', label: 'Research', date: 'today' },
|
||||
{ time: '11:30', status: 'waiting', text: 'Executor wartet auf Deployment-Freigabe.', label: 'System', date: 'today' },
|
||||
{ time: '15:22', status: 'done', text: 'Nexus Dashboard Migration geplant.', label: 'Coding', date: 'yesterday' },
|
||||
{ time: '16:05', status: 'done', text: 'Docker Compose Optimierung abgeschlossen.', label: 'System', date: 'yesterday' },
|
||||
]
|
||||
|
||||
const feedFilter = ref<string | null>(null)
|
||||
const filterLabels = ['Alle', 'Coding', 'Research', 'Personal', 'Memory', 'Agenten', 'System']
|
||||
|
||||
const filteredFeed = computed(() => {
|
||||
if (!feedFilter.value || feedFilter.value === 'Alle') return allFeed
|
||||
return allFeed.filter(item => item.label === feedFilter.value)
|
||||
})
|
||||
|
||||
const feedGroups = computed(() => {
|
||||
const groups: { date: string; items: FeedItem[] }[] = []
|
||||
const dates = ['today', 'yesterday', 'week'] as const
|
||||
for (const d of dates) {
|
||||
const items = filteredFeed.value.filter(i => i.date === d)
|
||||
if (items.length) {
|
||||
groups.push({
|
||||
date: d === 'today' ? 'Heute' : d === 'yesterday' ? 'Gestern' : 'Diese Woche',
|
||||
items,
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const statusColor = (s: FeedStatus): string => {
|
||||
const m: Record<FeedStatus, string> = {
|
||||
running: '#3b82f6',
|
||||
done: '#22c55e',
|
||||
waiting: '#eab308',
|
||||
error: '#ef4444',
|
||||
info: '#6b7385',
|
||||
}
|
||||
return m[s]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="feed-panel">
|
||||
<h2 class="feed-title">Operations Feed</h2>
|
||||
|
||||
<div class="filter-pills">
|
||||
<button
|
||||
v-for="label in filterLabels"
|
||||
:key="label"
|
||||
:class="{ active: feedFilter === label || (!feedFilter && label === 'Alle') }"
|
||||
@click="feedFilter = label === 'Alle' ? null : label"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="feed-list">
|
||||
<template v-for="group in feedGroups" :key="group.date">
|
||||
<div class="feed-date-heading">{{ group.date }}</div>
|
||||
<div
|
||||
v-for="(item, idx) in group.items"
|
||||
:key="idx"
|
||||
class="feed-item"
|
||||
>
|
||||
<span class="feed-time">{{ item.time }}</span>
|
||||
<span class="feed-dot" :style="{ background: statusColor(item.status) }"></span>
|
||||
<span class="feed-text">{{ item.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feed-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 420px;
|
||||
padding: 18px;
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.feed-panel:hover {
|
||||
border-color: rgba(139, 124, 246, 0.18);
|
||||
}
|
||||
.feed-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
/* Filter pills */
|
||||
.filter-pills {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.filter-pills::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.filter-pills button {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(139, 124, 246, 0.08);
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: #6b7385;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-pills button:hover {
|
||||
border-color: rgba(139, 124, 246, 0.25);
|
||||
color: #7e8799;
|
||||
}
|
||||
.filter-pills button.active {
|
||||
background: rgba(139, 124, 246, 0.12);
|
||||
border-color: rgba(139, 124, 246, 0.25);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
/* Feed list */
|
||||
.feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.feed-date-heading {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #6b7385;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
.feed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 6px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.feed-item:hover {
|
||||
background: rgba(139, 124, 246, 0.04);
|
||||
}
|
||||
.feed-time {
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.feed-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
.feed-text {
|
||||
font-size: 10.5px;
|
||||
line-height: 1.3;
|
||||
color: #7e8799;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.feed-panel {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const recentlyFinished = ref([
|
||||
'Docker Image gebaut',
|
||||
'Memory Compression',
|
||||
'Enemy AI verbessert',
|
||||
'Daily Backup',
|
||||
'TeamView deployt',
|
||||
'Config-Editor live',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="finished-section">
|
||||
<h3>Recently Finished</h3>
|
||||
<div class="finished-scroll">
|
||||
<span v-for="(item, idx) in recentlyFinished" :key="idx" class="finished-chip">
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finished-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.finished-section h3 {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #7e8799;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.finished-scroll {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.finished-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.finished-chip {
|
||||
flex-shrink: 0;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid rgba(139, 124, 246, 0.1);
|
||||
border-radius: 20px;
|
||||
background: rgba(139, 124, 246, 0.06);
|
||||
color: #7e8799;
|
||||
font-size: 9.5px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.finished-chip:hover {
|
||||
background: rgba(139, 124, 246, 0.12);
|
||||
border-color: rgba(139, 124, 246, 0.2);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.finished-section {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { Command, Search, CircleDot, Sparkles } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
connected: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggleMobileNav: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<button class="mobile-menu" @click="$emit('toggleMobileNav')">
|
||||
<Command :size="19" />
|
||||
</button>
|
||||
<div class="search">
|
||||
<Search :size="16" />
|
||||
<span>Search operations</span>
|
||||
<kbd>⌘ K</kbd>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span :class="['connection', connected ? 'live' : 'preview']">
|
||||
<CircleDot :size="13" />
|
||||
{{ connected ? 'Live' : 'Preview data' }}
|
||||
</span>
|
||||
<button class="ask"><Sparkles :size="15" /> Ask Iris</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--line, #1f2330);
|
||||
background: var(--panel, #11141b);
|
||||
}
|
||||
.mobile-menu { display: none; }
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--line, #1f2330);
|
||||
border-radius: 7px;
|
||||
color: var(--text-dim, #6f7889);
|
||||
font-size: 11px;
|
||||
}
|
||||
.search kbd {
|
||||
margin-left: auto;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid #2a2f3d;
|
||||
border-radius: 4px;
|
||||
font-size: 8px;
|
||||
color: #4a5266;
|
||||
}
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.connection.live { color: #27ae60; background: rgba(39,174,96,.1); }
|
||||
.connection.preview { color: #e67e22; background: rgba(230,126,34,.1); }
|
||||
.ask {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent, #7b6ef2);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--line, #1f2330); border-radius: 6px; background: transparent; color: var(--accent, #7b6ef2); cursor: pointer; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Activity, Bot, Boxes, Command, FileText,
|
||||
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
|
||||
Shield, SlidersHorizontal, Sparkles, Users, BookOpen,
|
||||
AlertTriangle, Calendar,
|
||||
} from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
activeView: string
|
||||
mobileNavOpen: boolean
|
||||
queuedTasks: number
|
||||
incidents: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [label: string]
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const ownerInitials = computed(() => auth.user?.displayName.split(' ').map(part => part[0]).join('').slice(0, 2).toUpperCase() ?? 'OW')
|
||||
|
||||
const navigation = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Memory', icon: FileText },
|
||||
{ label: 'Docs', icon: BookOpen },
|
||||
{ label: 'Team', icon: Users },
|
||||
{ label: 'Security', icon: Shield },
|
||||
{ label: 'Projects', icon: Boxes },
|
||||
{ label: 'Task Board', icon: ListTodo },
|
||||
{ label: 'Incidents', icon: AlertTriangle },
|
||||
{ separator: true },
|
||||
{ label: 'Calendar', icon: Calendar },
|
||||
{ label: 'Agents', icon: Bot },
|
||||
{ label: 'Models', icon: SlidersHorizontal },
|
||||
{ label: 'Activity', icon: Activity },
|
||||
{ label: 'Mobile Chat', icon: MessageSquareText },
|
||||
]
|
||||
|
||||
function onNavigate(label: string) {
|
||||
emit('navigate', label)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside :class="['sidebar', { open: mobileNavOpen }]">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><Command :size="18" /></div>
|
||||
<div>
|
||||
<strong>NEXUS</strong>
|
||||
<span>Noveria Operations</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<template v-for="item in navigation" :key="item.label ?? 'sep'">
|
||||
<div v-if="item.separator" class="nav-separator"></div>
|
||||
<button
|
||||
v-else
|
||||
:class="{ active: activeView === item.label }"
|
||||
@click="onNavigate(item.label)"
|
||||
>
|
||||
<component :is="item.icon" :size="17" />
|
||||
<span>{{ item.label }}</span>
|
||||
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
|
||||
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
<button :class="{ active: activeView === 'Settings' }" @click="onNavigate('Settings')"><Settings :size="17" /> Settings</button>
|
||||
<button class="owner" type="button" title="Sign out" @click="logout">
|
||||
<div class="avatar">{{ ownerInitials }}</div>
|
||||
<div><strong>{{ auth.user?.displayName ?? 'Owner' }}</strong><span>{{ auth.user?.role ?? 'owner' }}</span></div>
|
||||
<LogOut :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 210px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel, #11141b);
|
||||
border-right: 1px solid var(--line, #1f2330);
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 10px 12px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 7px;
|
||||
background: var(--accent, #7b6ef2);
|
||||
color: #fff;
|
||||
}
|
||||
.brand div strong { display: block; font-size: 10px; letter-spacing: .08em; }
|
||||
.brand div span { font-size: 8px; color: var(--text-dim, #6f7889); }
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 4px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.nav button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #9ea5b3;
|
||||
font-size: 10.5px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.nav button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
|
||||
.nav button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
|
||||
.nav button i {
|
||||
margin-left: auto;
|
||||
background: var(--accent, #7b6ef2);
|
||||
color: #fff;
|
||||
font-style: normal;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.nav-separator {
|
||||
height: 1px;
|
||||
margin: 6px 10px;
|
||||
background: var(--line, #1f2330);
|
||||
}
|
||||
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--line, #1f2330); }
|
||||
.sidebar-bottom > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #9ea5b3;
|
||||
font-size: 10.5px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.sidebar-bottom > button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
|
||||
.sidebar-bottom > button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
|
||||
.owner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.owner div strong { display: block; font-size: 9px; }
|
||||
.owner div span { font-size: 7.5px; color: var(--text-dim, #6f7889); text-transform: capitalize; }
|
||||
.owner > svg:last-child { margin-left: auto; opacity: .4; transition: opacity .15s; }
|
||||
.owner:hover > svg:last-child { opacity: 1; }
|
||||
.avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent, #7b6ef2);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.sidebar { position: fixed; inset: 0; z-index: 100; transform: translateX(-100%); transition: transform .25s; }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { Bot, Code2, Server, Shield, Search, Terminal } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
tags: string[]
|
||||
hero?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
click: [id: string]
|
||||
}>()
|
||||
|
||||
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>
|
||||
<article
|
||||
:class="['agent-card', { 'hero-card': hero }]"
|
||||
:style="{ '--card-color': color }"
|
||||
@click="$emit('click', id)"
|
||||
>
|
||||
<div class="card-main">
|
||||
<div class="card-icon-wrap" :style="{ background: `${color}18`, color: color }">
|
||||
<component :is="resolveIcon(icon)" :size="hero ? 20 : 18" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-name-row">
|
||||
<h3 class="card-name">{{ name }}</h3>
|
||||
<span class="card-role-tag" :style="{ background: `${color}18`, color: color, borderColor: `${color}30` }">{{ role }}</span>
|
||||
</div>
|
||||
<p class="card-desc">{{ description }}</p>
|
||||
<div class="card-tags">
|
||||
<span v-for="tag in tags" :key="tag" class="card-tag" :style="{ background: `${color}18`, color: color }">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer-action">
|
||||
<span>ROLE CARD</span>
|
||||
<span class="arrow">→</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-card {
|
||||
background: var(--panel, #11141b);
|
||||
border: 1px solid var(--line, #1f2330);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.agent-card:hover {
|
||||
border-color: var(--card-color, #8b7cf6);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--card-color, #8b7cf6) 10%, transparent);
|
||||
}
|
||||
.hero-card {
|
||||
border-color: rgba(139, 124, 246, 0.2);
|
||||
box-shadow: 0 0 20px rgba(139, 124, 246, 0.06);
|
||||
}
|
||||
.hero-card:hover {
|
||||
border-color: #8b7cf6;
|
||||
box-shadow: 0 0 24px rgba(139, 124, 246, 0.12);
|
||||
}
|
||||
.card-main {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.card-icon-wrap {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.card-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-name {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.card-role-tag {
|
||||
display: inline-block;
|
||||
font-size: 8.5px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 10.5px;
|
||||
color: #7e8799;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.card-tag {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.card-footer-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line, #1f2330);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #6b7385;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.card-footer-action .arrow {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
.agent-card:hover .card-footer-action {
|
||||
color: var(--card-color, #8b7cf6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
status: string
|
||||
}>()
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
Online: '#51d49a',
|
||||
Degraded: '#e5b05e',
|
||||
Offline: '#e16e75',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="status-badge"
|
||||
:style="{ background: (statusColors[status] || '#7e8799') + '18', color: statusColors[status] || '#7e8799' }"
|
||||
>
|
||||
<span class="status-dot" :style="{ background: statusColors[status] || '#7e8799' }"></span>
|
||||
{{ status }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function useTime() {
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 11) return 'Guten Morgen'
|
||||
if (h < 18) return 'Guten Tag'
|
||||
return 'Guten Abend'
|
||||
})
|
||||
|
||||
return { greeting }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import './style.css'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
router.beforeEach(async to => {
|
||||
const auth = useAuthStore(pinia)
|
||||
await auth.initialize()
|
||||
|
||||
if (to.meta.public) {
|
||||
return to.name === 'Login' && auth.isAuthenticated ? '/dashboard' : true
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
createApp(App)
|
||||
.use(pinia)
|
||||
.use(router)
|
||||
.mount('#app')
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import LoginView from './views/LoginView.vue'
|
||||
import ProjectDetailView from './views/ProjectDetailView.vue'
|
||||
import SettingsView from './views/SettingsView.vue'
|
||||
import MemoryView from './views/MemoryView.vue'
|
||||
import DocsView from './views/DocsView.vue'
|
||||
import TeamView from './views/TeamView.vue'
|
||||
import AgentDetailView from './views/AgentDetailView.vue'
|
||||
import AgentsIndexView from './views/AgentsIndexView.vue'
|
||||
import SecurityView from './views/SecurityView.vue'
|
||||
import IncidentsView from './views/IncidentsView.vue'
|
||||
import CalendarView from './views/CalendarView.vue'
|
||||
import DashboardView from './views/DashboardView.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
{ path: '/dashboard', name: 'Dashboard', component: DashboardView },
|
||||
{ path: '/memory', name: 'Memory', component: MemoryView },
|
||||
{ path: '/docs', name: 'Docs', component: DocsView },
|
||||
{ path: '/team', name: 'Team', component: TeamView },
|
||||
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView },
|
||||
{ path: '/security', name: 'Security', component: SecurityView },
|
||||
{ path: '/incidents', name: 'Incidents', component: IncidentsView },
|
||||
{ path: '/calendar', name: 'Calendar', component: CalendarView },
|
||||
{ path: '/projects', name: 'Projects', component: { template: '' } },
|
||||
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
|
||||
{ path: '/tasks', name: 'Task Board', component: { template: '' } },
|
||||
{ path: '/agents', name: 'Agents', component: AgentsIndexView },
|
||||
{ path: '/models', name: 'Models', component: { template: '' } },
|
||||
{ path: '/activity', name: 'Activity', component: { template: '' } },
|
||||
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
|
||||
{ path: '/settings', name: 'Settings', component: SettingsView },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||
]
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.initialized) await auth.initialize()
|
||||
|
||||
const send = () => {
|
||||
const headers = new Headers(init.headers)
|
||||
if (auth.accessToken) headers.set('Authorization', `Bearer ${auth.accessToken}`)
|
||||
return fetch(input, { ...init, headers, credentials: 'include' })
|
||||
}
|
||||
|
||||
let response = await send()
|
||||
if (response.status !== 401) return response
|
||||
|
||||
const refreshed = await auth.refresh()
|
||||
if (!refreshed) {
|
||||
if (window.location.pathname !== '/login') window.location.assign('/login')
|
||||
return response
|
||||
}
|
||||
|
||||
response = await send()
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
displayName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface AuthPayload {
|
||||
accessToken: string
|
||||
expiresAt: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
let refreshInFlight: Promise<boolean> | null = null
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
accessToken: null as string | null,
|
||||
expiresAt: null as string | null,
|
||||
user: null as AuthUser | null,
|
||||
initialized: false,
|
||||
loading: false,
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: state => Boolean(state.accessToken && state.user),
|
||||
},
|
||||
actions: {
|
||||
applySession(payload: AuthPayload) {
|
||||
this.accessToken = payload.accessToken
|
||||
this.expiresAt = payload.expiresAt
|
||||
this.user = payload.user
|
||||
},
|
||||
clearSession() {
|
||||
this.accessToken = null
|
||||
this.expiresAt = null
|
||||
this.user = null
|
||||
},
|
||||
async initialize() {
|
||||
if (this.initialized) return this.isAuthenticated
|
||||
this.initialized = true
|
||||
return this.refresh()
|
||||
},
|
||||
async login(email: string, password: string) {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) throw new Error('Too many attempts. Please wait one minute.')
|
||||
throw new Error('Invalid email or password.')
|
||||
}
|
||||
|
||||
this.applySession(await response.json() as AuthPayload)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async refresh(): Promise<boolean> {
|
||||
if (refreshInFlight) return refreshInFlight
|
||||
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!response.ok) {
|
||||
this.clearSession()
|
||||
return false
|
||||
}
|
||||
|
||||
this.applySession(await response.json() as AuthPayload)
|
||||
return true
|
||||
} catch {
|
||||
this.clearSession()
|
||||
return false
|
||||
} finally {
|
||||
refreshInFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return refreshInFlight
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : undefined,
|
||||
})
|
||||
} finally {
|
||||
this.clearSession()
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
|
||||
import { apiFetch } from '../services/api'
|
||||
|
||||
const fallback: OperationsSnapshot = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtime: { runtime: 'OpenClaw', status: 'Online', detail: 'Gateway responding' },
|
||||
models: [
|
||||
{ provider: 'OpenClaw', model: 'deepseek/deepseek-v4-flash', status: 'Online', isLocal: false, detail: 'Programmer agent' },
|
||||
{ provider: 'OpenClaw', model: 'deepseek/deepseek-v4-pro', status: 'Online', isLocal: false, detail: 'Reviewer agent' },
|
||||
{ provider: 'OpenClaw', model: 'openai/gpt-5.3-chat-latest', status: 'Online', isLocal: false, detail: 'Iris orchestrator' },
|
||||
],
|
||||
metrics: { activeAgents: 3, queuedTasks: 7, successRate: 98.4, incidents: 0 },
|
||||
projects: [
|
||||
{ id: 'nexus', name: 'Nexus', status: 'Active', progress: 18 },
|
||||
{ id: 'openclaw', name: 'OpenClaw Runtime', status: 'Online', progress: 100 },
|
||||
{ id: 'infra', name: 'Noveria Infrastructure', status: 'Stable', progress: 74 },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'preview-foundation', title: 'Nexus foundation', state: 'In progress', priority: 'Critical', updatedAt: new Date().toISOString() },
|
||||
{ id: 'preview-runtime', title: 'Connect OpenClaw adapter', state: 'In progress', priority: 'High', updatedAt: new Date().toISOString() },
|
||||
{ id: 'preview-routing', title: 'Configure model routing', state: 'In progress', priority: 'High', updatedAt: new Date().toISOString() },
|
||||
{ id: 'preview-auth', title: 'Owner authentication', state: 'Done', priority: 'Critical', updatedAt: new Date().toISOString() },
|
||||
],
|
||||
activity: [
|
||||
{ type: 'runtime', message: 'OpenClaw runtime health checked', at: new Date().toISOString() },
|
||||
{ type: 'deploy', message: 'Nexus foundation initialized', at: new Date(Date.now() - 720000).toISOString() },
|
||||
{ type: 'deploy', message: 'Model routing configured for DeepSeek agents', at: new Date(Date.now() - 1140000).toISOString() },
|
||||
],
|
||||
}
|
||||
|
||||
const fallbackRouting: RoutingTarget[] = [
|
||||
{ priority: 1, provider: 'OpenClaw', model: 'deepseek/deepseek-v4-flash', purpose: 'Programmer agent', status: 'Online', detail: 'Routed through OpenClaw' },
|
||||
{ priority: 2, provider: 'OpenClaw', model: 'deepseek/deepseek-v4-pro', purpose: 'Reviewer agent', status: 'Online', detail: 'Routed through OpenClaw' },
|
||||
{ priority: 3, provider: 'OpenClaw', model: 'openai/gpt-5.3-chat-latest', purpose: 'Iris orchestrator', status: 'Online', detail: 'Routed through OpenClaw' },
|
||||
]
|
||||
|
||||
export const useOperationsStore = defineStore('operations', {
|
||||
state: () => ({
|
||||
snapshot: fallback,
|
||||
routing: fallbackRouting,
|
||||
loading: false,
|
||||
connected: false,
|
||||
}),
|
||||
actions: {
|
||||
async createProject(name: string) {
|
||||
const response = await apiFetch('/api/v1/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!response.ok) throw new Error('Project could not be created')
|
||||
const project = await response.json()
|
||||
this.snapshot.projects.unshift({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
status: project.status,
|
||||
progress: project.progress,
|
||||
})
|
||||
},
|
||||
async createTask(title: string, priority: string) {
|
||||
const response = await apiFetch('/api/v1/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, priority }),
|
||||
})
|
||||
if (!response.ok) throw new Error('Task could not be created')
|
||||
const task = await response.json()
|
||||
this.snapshot.tasks.unshift(task)
|
||||
this.snapshot.metrics.queuedTasks += 1
|
||||
},
|
||||
async updateTaskState(id: string, state: string) {
|
||||
const response = await apiFetch(`/api/v1/tasks/${id}/state`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state }),
|
||||
})
|
||||
if (!response.ok) throw new Error('Task state could not be updated')
|
||||
const updatedTask = await response.json()
|
||||
const index = this.snapshot.tasks.findIndex(task => task.id === id)
|
||||
if (index !== -1) this.snapshot.tasks[index] = updatedTask
|
||||
this.snapshot.metrics.queuedTasks = this.snapshot.tasks.filter(task => task.state !== 'Done').length
|
||||
this.snapshot.metrics.incidents = this.snapshot.tasks.filter(task => task.state === 'Blocked').length
|
||||
const completed = this.snapshot.tasks.filter(task => task.state === 'Done').length
|
||||
this.snapshot.metrics.successRate = this.snapshot.tasks.length
|
||||
? Math.round((completed * 1000) / this.snapshot.tasks.length) / 10
|
||||
: 100
|
||||
},
|
||||
async updateTask(id: string, data: { title?: string; priority?: string; projectId?: string | null }) {
|
||||
const response = await apiFetch(`/api/v1/tasks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) throw new Error('Task could not be updated')
|
||||
const updatedTask = await response.json()
|
||||
const index = this.snapshot.tasks.findIndex(task => task.id === id)
|
||||
if (index !== -1) this.snapshot.tasks[index] = updatedTask
|
||||
},
|
||||
async updateProject(id: string, data: { name?: string; description?: string; status?: string }) {
|
||||
const response = await apiFetch(`/api/v1/projects/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) throw new Error('Project could not be updated')
|
||||
const updatedProject = await response.json()
|
||||
const index = this.snapshot.projects.findIndex(p => p.id === id)
|
||||
if (index !== -1) this.snapshot.projects[index] = {
|
||||
id: updatedProject.id,
|
||||
name: updatedProject.name,
|
||||
status: updatedProject.status,
|
||||
progress: updatedProject.progress,
|
||||
}
|
||||
},
|
||||
async deleteTask(id: string) {
|
||||
const response = await apiFetch(`/api/v1/tasks/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (response.status === 403) {
|
||||
const err = await response.json().catch(() => ({ detail: 'Task cannot be deleted in its current state.' }))
|
||||
throw new Error(err.detail || 'Task cannot be deleted in its current state.')
|
||||
}
|
||||
if (!response.ok) throw new Error('Task could not be deleted')
|
||||
this.snapshot.tasks = this.snapshot.tasks.filter(t => t.id !== id)
|
||||
this.snapshot.metrics.queuedTasks = this.snapshot.tasks.filter(t => t.state !== 'Done').length
|
||||
const completed = this.snapshot.tasks.filter(t => t.state === 'Done').length
|
||||
this.snapshot.metrics.successRate = this.snapshot.tasks.length
|
||||
? Math.round((completed * 1000) / this.snapshot.tasks.length) / 10
|
||||
: 100
|
||||
},
|
||||
async deleteProject(id: string) {
|
||||
const response = await apiFetch(`/api/v1/projects/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) throw new Error('Project could not be deleted')
|
||||
this.snapshot.projects = this.snapshot.projects.filter(p => p.id !== id)
|
||||
},
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
try {
|
||||
const [snapshotResponse, routingResponse] = await Promise.all([
|
||||
apiFetch('/api/v1/operations/snapshot'),
|
||||
apiFetch('/api/v1/routing'),
|
||||
])
|
||||
if (!snapshotResponse.ok || !routingResponse.ok) throw new Error('Nexus API unavailable')
|
||||
this.snapshot = await snapshotResponse.json()
|
||||
this.routing = await routingResponse.json()
|
||||
this.connected = true
|
||||
} catch {
|
||||
this.connected = false
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async fetchAgents(): Promise<AgentInfo[]> {
|
||||
try {
|
||||
const response = await apiFetch('/api/v1/agents')
|
||||
if (!response.ok) throw new Error('Failed to fetch agents')
|
||||
return await response.json()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
async approveTask(id: string) {
|
||||
const response = await apiFetch(`/api/v1/tasks/${id}/approve`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Task could not be approved')
|
||||
const index = this.snapshot.tasks.findIndex(task => task.id === id)
|
||||
if (index !== -1) {
|
||||
this.snapshot.tasks.splice(index, 1)
|
||||
}
|
||||
this.snapshot.metrics.queuedTasks = this.snapshot.tasks.filter(task => task.state !== 'Done').length
|
||||
this.snapshot.metrics.incidents = this.snapshot.tasks.filter(task => task.state === 'Blocked').length
|
||||
const completed = this.snapshot.tasks.filter(task => task.state === 'Done').length
|
||||
this.snapshot.metrics.successRate = this.snapshot.tasks.length
|
||||
? Math.round((completed * 1000) / this.snapshot.tasks.length) / 10
|
||||
: 100
|
||||
},
|
||||
async rejectTask(id: string) {
|
||||
const response = await apiFetch(`/api/v1/tasks/${id}/reject`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Task could not be rejected')
|
||||
const index = this.snapshot.tasks.findIndex(task => task.id === id)
|
||||
if (index !== -1) {
|
||||
this.snapshot.tasks[index] = { ...this.snapshot.tasks[index], state: 'Backlog' }
|
||||
}
|
||||
this.snapshot.metrics.queuedTasks = this.snapshot.tasks.filter(task => task.state !== 'Done').length
|
||||
this.snapshot.metrics.incidents = this.snapshot.tasks.filter(task => task.state === 'Blocked').length
|
||||
const completed = this.snapshot.tasks.filter(task => task.state === 'Done').length
|
||||
this.snapshot.metrics.successRate = this.snapshot.tasks.length
|
||||
? Math.round((completed * 1000) / this.snapshot.tasks.length) / 10
|
||||
: 100
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #e8eaf0;
|
||||
background: #080a0f;
|
||||
font-synthesis: none;
|
||||
--panel: #10131a;
|
||||
--panel-soft: #0d1016;
|
||||
--line: #202530;
|
||||
--muted: #7e8799;
|
||||
--accent: #8b7cf6;
|
||||
--accent-soft: rgba(139, 124, 246, .12);
|
||||
--green: #51d49a;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 70% -20%, #17152b 0, transparent 34%), #080a0f; }
|
||||
button { color: inherit; font: inherit; }
|
||||
.shell { min-height: 100vh; display: grid; grid-template-columns: 224px 1fr; }
|
||||
.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; padding: 22px 14px 14px; border-right: 1px solid #1a1e27; background: rgba(9, 11, 16, .94); backdrop-filter: blur(18px); }
|
||||
.brand { display: flex; align-items: center; gap: 11px; padding: 0 8px 25px; }
|
||||
.brand-mark { width: 35px; height: 35px; display: grid; place-items: center; border: 1px solid #443d7c; border-radius: 10px; background: linear-gradient(145deg, #241f44, #12121f); color: #b8adff; box-shadow: 0 0 24px rgba(139,124,246,.13); }
|
||||
.brand strong { display: block; font-size: 13px; letter-spacing: .14em; }
|
||||
.brand span, .owner span { display: block; color: var(--muted); font-size: 10px; margin-top: 2px; }
|
||||
.nav { display: flex; flex-direction: column; gap: 3px; }
|
||||
.nav button, .sidebar-bottom > button { width: 100%; display: flex; align-items: center; gap: 10px; border: 0; padding: 9px 10px; border-radius: 7px; background: transparent; color: #8991a1; font-size: 12px; text-align: left; cursor: pointer; }
|
||||
.nav button:hover, .nav button.active { color: #ececf5; background: var(--accent-soft); }
|
||||
.nav button.active { box-shadow: inset 2px 0 var(--accent); }
|
||||
.nav button i { margin-left: auto; padding: 1px 6px; border: 1px solid #343947; border-radius: 8px; font-size: 9px; font-style: normal; }
|
||||
.sidebar-bottom { margin-top: auto; border-top: 1px solid #1b1f28; padding-top: 10px; }
|
||||
.owner { display: grid; grid-template-columns: 31px 1fr auto; gap: 9px; align-items: center; margin-top: 10px; padding: 10px 8px; }
|
||||
.owner strong { font-size: 11px; }
|
||||
.avatar { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 50%; background: #28243f; color: #bcb3ff; font-size: 10px; }
|
||||
main { min-width: 0; }
|
||||
.topbar { height: 62px; display: flex; align-items: center; justify-content: space-between; padding: 0 30px; border-bottom: 1px solid #191d25; background: rgba(8,10,15,.68); backdrop-filter: blur(16px); }
|
||||
.search { width: min(390px, 42vw); display: flex; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid #202530; border-radius: 7px; color: #6f7889; font-size: 11px; }
|
||||
.search kbd { margin-left: auto; padding: 2px 5px; border: 1px solid #2c313d; border-radius: 4px; color: #606979; font-size: 9px; }
|
||||
.top-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.connection { display: flex; gap: 6px; align-items: center; font-size: 10px; color: #8c95a5; }
|
||||
.connection.live { color: var(--green); }
|
||||
.connection.preview { color: #e6b75d; }
|
||||
.ask, .refresh { display: flex; align-items: center; gap: 7px; padding: 8px 11px; border: 1px solid #37315e; border-radius: 7px; background: #18152a; color: #c4bbff; font-size: 10px; cursor: pointer; }
|
||||
.content { max-width: 1320px; margin: auto; padding: 36px 34px 60px; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 28px; }
|
||||
.eyebrow, .kicker { color: #7065c8; font-size: 9px; font-weight: 700; letter-spacing: .18em; }
|
||||
h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
|
||||
.page-heading p, .placeholder p { margin: 0; color: var(--muted); font-size: 11px; }
|
||||
.refresh { border-color: var(--line); background: var(--panel); color: #a5adba; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 10px; }
|
||||
.metrics article, .panel { border: 1px solid var(--line); background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); border-radius: 9px; }
|
||||
.metrics article { padding: 16px 17px; }
|
||||
.metrics span { color: #717a8a; font-size: 8px; font-weight: 700; letter-spacing: .14em; }
|
||||
.metrics strong { display: block; margin: 7px 0 5px; font-size: 24px; letter-spacing: -.04em; }
|
||||
.metrics small { color: #687181; font-size: 9px; }
|
||||
.metrics small.up { color: #55c995; }
|
||||
.dashboard-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.panel { padding: 18px; min-height: 180px; }
|
||||
.span-2 { grid-column: span 2; }
|
||||
.panel-head { display: flex; align-items: center; justify-content: space-between; padding-bottom: 15px; border-bottom: 1px solid #1d222c; }
|
||||
.panel-head h2 { margin: 4px 0 0; font-size: 13px; }
|
||||
.panel-head button { border: 0; background: transparent; color: #8e96a5; font-size: 9px; }
|
||||
.badge { padding: 4px 8px; border-radius: 10px; font-size: 8px; }
|
||||
.badge.positive { color: var(--green); background: rgba(81,212,154,.1); }
|
||||
.badge.warning { color: #e7b660; background: rgba(231,182,96,.1); }
|
||||
.runtime-row { display: flex; align-items: center; gap: 12px; padding-top: 22px; }
|
||||
.runtime-icon { width: 45px; height: 45px; display: grid; place-items: center; border-radius: 9px; color: #ad9fff; background: var(--accent-soft); }
|
||||
.runtime-main strong, .model strong, .project strong, .event strong { display: block; font-size: 11px; }
|
||||
.runtime-main span, .model small, .event small { display: block; margin-top: 4px; color: var(--muted); font-size: 9px; }
|
||||
.pulse-bars { height: 42px; display: flex; align-items: center; gap: 3px; margin-left: auto; }
|
||||
.pulse-bars i { width: 3px; min-height: 5px; border-radius: 3px; background: linear-gradient(#927fff, #443b7c); }
|
||||
.model { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 9px; padding: 12px 2px; border-bottom: 1px solid #1b2029; }
|
||||
.model > span:last-child { color: #687181; font-size: 8px; }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: #657083; }
|
||||
.status-dot.online { background: var(--green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
|
||||
.status-dot.offline { background: #e16e75; }
|
||||
.project { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid #1b2029; }
|
||||
.project-letter { width: 31px; height: 31px; display: grid; place-items: center; border: 1px solid #353047; border-radius: 7px; color: #a99cf5; font-size: 10px; }
|
||||
.project-info > div:first-child { display: flex; justify-content: space-between; }
|
||||
.project-info span { color: var(--muted); font-size: 8px; }
|
||||
.project b { color: #838c9c; font-size: 9px; }
|
||||
.progress { height: 3px; margin-top: 8px; overflow: hidden; border-radius: 4px; background: #242936; }
|
||||
.progress i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #685ac8, #a091ff); }
|
||||
.event { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 12px 0; border-bottom: 1px solid #1b2029; }
|
||||
.event > span { width: 6px; height: 6px; margin-top: 4px; border-radius: 50%; background: #657083; }
|
||||
.event > span.runtime { background: var(--green); }
|
||||
.event > span.deploy { background: #8b7cf6; }
|
||||
.event > span.security { background: #e5ad52; }
|
||||
.placeholder { min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
|
||||
.placeholder svg { margin-bottom: 18px; color: #8074d8; }
|
||||
.placeholder h2 { margin: 8px 0; }
|
||||
.mobile-menu { display: none; border: 0; background: transparent; color: #aaa4e7; }
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: fixed; z-index: 20; left: -240px; width: 224px; transition: left .2s ease; }
|
||||
.sidebar.open { left: 0; box-shadow: 20px 0 60px #000; }
|
||||
.mobile-menu { display: block; }
|
||||
.topbar { padding: 0 18px; }
|
||||
.metrics { grid-template-columns: repeat(2, 1fr); }
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.span-2 { grid-column: span 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.content { padding: 26px 16px 40px; }
|
||||
.search { display: none; }
|
||||
.topbar { justify-content: space-between; }
|
||||
.metrics { grid-template-columns: 1fr 1fr; }
|
||||
.page-heading { align-items: start; }
|
||||
.page-heading p { max-width: 220px; line-height: 1.5; }
|
||||
.runtime-row { flex-wrap: wrap; }
|
||||
.pulse-bars { width: 100%; margin-left: 57px; }
|
||||
}
|
||||
|
||||
|
||||
.module-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.module-card { min-height: 190px; padding: 18px; border: 1px solid var(--line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); }
|
||||
.module-card h3, .model-detail h3, .timeline h3, .chat-shell h3 { margin: 8px 0 4px; font-size: 13px; }
|
||||
.module-card p, .model-detail p, .timeline p, .chat-shell p { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||
.module-card-head, .project-card footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.project-card .progress { margin: 34px 0 12px; }
|
||||
.project-card footer { color: var(--muted); font-size: 9px; }
|
||||
.kanban { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; align-items: start; }
|
||||
.kanban-column { min-height: 380px; padding: 12px; border: 1px solid var(--line); border-radius: 9px; background: rgba(14,17,23,.8); }
|
||||
.kanban-column > header { display: flex; justify-content: space-between; padding: 5px 3px 14px; color: #aeb4c0; font-size: 10px; }
|
||||
.kanban-column > header b { padding: 1px 6px; border-radius: 8px; background: #242936; }
|
||||
.task-card { margin-bottom: 9px; padding: 14px; border: 1px solid #252a35; border-radius: 8px; background: #12151c; }
|
||||
.task-card h3 { margin: 10px 0 22px; font-size: 11px; }
|
||||
.task-card select { width: 100%; margin-bottom: 12px; padding: 7px 8px; border: 1px solid #292f3b; border-radius: 7px; outline: none; color: #c8ccd5; background: #0c0f14; font: inherit; font-size: 9px; cursor: pointer; }
|
||||
.task-card footer { display: flex; gap: 5px; align-items: center; color: var(--muted); font-size: 8px; }
|
||||
.priority { font-size: 8px; text-transform: uppercase; color: #9b91e6; }.priority.critical { color: #ec7b82; }.priority.high { color: #e5b05e; }
|
||||
.empty-state { padding: 35px 0; text-align: center; color: #596171; font-size: 9px; }
|
||||
.agent-card { display: grid; grid-template-columns: auto 1fr auto; gap: 13px; align-items: start; }
|
||||
.agent-avatar { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 9px; color: #66d5a4; background: rgba(81,212,154,.1); }
|
||||
.agent-avatar.violet { color: #a99cff; background: var(--accent-soft); }
|
||||
.module-list { padding: 4px 18px; }
|
||||
.model-detail { display: grid; grid-template-columns: 45px 1fr auto; align-items: center; gap: 14px; padding: 19px 0; border-bottom: 1px solid #1d222c; }
|
||||
.route-rank { color: #6f63c9; font-size: 12px; font-weight: 700; }
|
||||
.timeline { padding: 6px 20px; }
|
||||
.timeline article { display: grid; grid-template-columns: 34px 1fr; gap: 12px; padding: 18px 0; border-bottom: 1px solid #1d222c; }
|
||||
.timeline-icon { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; color: #70d8aa; background: rgba(81,212,154,.1); }.timeline-icon.security { color: #e5b05e; background: rgba(229,176,94,.1); }
|
||||
.chat-shell { max-width: 760px; margin: auto; padding: 0; overflow: hidden; }
|
||||
.chat-shell > header { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 16px; border-bottom: 1px solid var(--line); }
|
||||
.messages { min-height: 360px; padding: 22px; }
|
||||
.message { max-width: 75%; margin-bottom: 12px; padding: 12px 14px; border-radius: 10px; background: #171b23; }.message.owner { margin-left: auto; background: #211d39; }.message strong { font-size: 9px; color: #9d91eb; }
|
||||
.chat-shell form { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 14px; border-top: 1px solid var(--line); }
|
||||
.chat-shell input { min-width: 0; padding: 11px 13px; border: 1px solid #292f3b; border-radius: 8px; outline: none; color: #e7e9ef; background: #0c0f14; font: inherit; font-size: 10px; }
|
||||
.chat-shell form button { width: 38px; border: 1px solid #443d7c; border-radius: 8px; color: #beb4ff; background: #211d39; }
|
||||
@media (max-width: 900px) { .module-grid, .kanban { grid-template-columns: 1fr; } }
|
||||
.quick-create { display: grid; grid-template-columns: minmax(180px, 360px) auto; gap: 8px; margin-bottom: 12px; }
|
||||
.quick-create input { padding: 10px 12px; border: 1px solid #292f3b; border-radius: 8px; outline: none; color: #e7e9ef; background: #0c0f14; font: inherit; font-size: 10px; }
|
||||
.quick-create button { padding: 0 14px; border: 1px solid #443d7c; border-radius: 8px; color: #c1b8ff; background: #211d39; font-size: 9px; cursor: pointer; }
|
||||
.message.error { color: #e9a1a5; background: rgba(193,70,79,.12); border: 1px solid rgba(225,110,117,.2); }
|
||||
.message.pending { opacity: .68; }
|
||||
.chat-shell input:disabled, .chat-shell button:disabled { cursor: wait; opacity: .55; }
|
||||
.settings-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.settings-grid .module-card { min-height: 165px; }
|
||||
.settings-grid .badge { display: inline-block; margin-top: 28px; }
|
||||
.sidebar-bottom > button.active { color: #ececf5; background: var(--accent-soft); }
|
||||
@media (max-width: 700px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.owner { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
|
||||
.owner:hover { background: var(--accent-soft); border-radius: 8px; }
|
||||
|
||||
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
||||
.login-card { width: min(420px, 100%); padding: 32px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(145deg, rgba(18,21,29,.98), rgba(10,12,18,.98)); box-shadow: 0 28px 90px rgba(0,0,0,.4); }
|
||||
.login-brand { display: flex; align-items: center; gap: 12px; padding-bottom: 28px; border-bottom: 1px solid #1d222c; }
|
||||
.login-brand strong { display: block; font-size: 13px; letter-spacing: .14em; }
|
||||
.login-brand span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; }
|
||||
.login-heading { padding: 28px 0 20px; }
|
||||
.login-heading h1 { margin-top: 9px; font-size: 25px; }
|
||||
.login-heading p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||
.login-card form { display: grid; gap: 14px; }
|
||||
.login-card label span { display: block; margin-bottom: 7px; color: #aab1bf; font-size: 10px; }
|
||||
.login-card input { width: 100%; padding: 12px 13px; border: 1px solid #2a303b; border-radius: 8px; outline: none; color: #eef0f5; background: #0a0d12; font: inherit; font-size: 12px; }
|
||||
.login-card input:focus { border-color: #6559b8; box-shadow: 0 0 0 3px rgba(139,124,246,.1); }
|
||||
.login-card form button { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 5px; padding: 12px; border: 1px solid #554a9b; border-radius: 8px; color: #e6e2ff; background: linear-gradient(135deg, #332b68, #211d43); cursor: pointer; }
|
||||
.login-card form button:disabled { cursor: wait; opacity: .6; }
|
||||
.login-error { margin: 0; padding: 10px 12px; border: 1px solid rgba(225,110,117,.25); border-radius: 8px; color: #efa2a7; background: rgba(193,70,79,.1); font-size: 10px; }
|
||||
.login-card footer { margin-top: 24px; color: #626b7b; font-size: 8px; line-height: 1.5; text-align: center; }
|
||||
|
||||
/* Project health row */
|
||||
.project-health-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 10px; }
|
||||
.health-card { padding: 12px 16px; border: 1px solid var(--line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); text-align: center; }
|
||||
.health-label { display: block; color: #717a8a; font-size: 8px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.health-card strong { display: block; margin-top: 6px; font-size: 22px; letter-spacing: -.04em; }
|
||||
.health-online { color: var(--green); }
|
||||
.health-degraded { color: #e7b660; }
|
||||
.health-offline { color: #e16e75; }
|
||||
.health-unknown { color: #7e8799; }
|
||||
|
||||
/* Runtime health indicators */
|
||||
.runtime-health-row { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
|
||||
.runtime-health-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.runtime-health-dot.healthy { background: var(--green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
|
||||
.runtime-health-dot.unhealthy { background: #e7b660; box-shadow: 0 0 7px rgba(231,182,96,.3); }
|
||||
.runtime-health-text { font-size: 9px; color: var(--muted); }
|
||||
.runtime-incident { margin-top: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.incident-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border: 1px solid rgba(229,176,94,.3); border-radius: 6px; background: rgba(229,176,94,.08); color: #e5b05e; font-size: 9px; }
|
||||
|
||||
/* Snapshot agents in dashboard */
|
||||
.snapshot-agents { margin-top: 14px; padding-top: 14px; border-top: 1px solid #1d222c; }
|
||||
.snapshot-agents-head { display: flex; align-items: center; gap: 6px; font-size: 9px; color: #7065c8; font-weight: 700; letter-spacing: .1em; margin-bottom: 8px; text-transform: uppercase; }
|
||||
.snapshot-agents-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.snapshot-agent-item { display: flex; align-items: center; gap: 7px; padding: 4px 0; }
|
||||
.snapshot-agent-item .name { font-size: 10px; color: #e8eaf0; }
|
||||
.snapshot-agent-item .role-tag { margin-left: auto; font-size: 8px; padding: 1px 6px; border: 1px solid #343947; border-radius: 6px; color: #8991a1; }
|
||||
.status-dot--sm { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot--sm.online { background: var(--green); }
|
||||
.status-dot--sm.degraded { background: #e7b660; }
|
||||
.status-dot--sm.offline { background: #e16e75; }
|
||||
|
||||
/* Badge negative for offline */
|
||||
.badge.negative { color: #e16e75; background: rgba(225,110,117,.1); }
|
||||
|
||||
@media (max-width: 900px) { .project-health-row { grid-template-columns: repeat(2, 1fr); } }
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface AgentInfo {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
model: string
|
||||
status: string
|
||||
lastSeen?: string
|
||||
workspace?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface AgentDetail extends AgentInfo {
|
||||
agentDir?: string
|
||||
subAgents?: string[]
|
||||
identityName?: string
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
model: string
|
||||
status: string
|
||||
lastSeen?: string
|
||||
workspace?: string
|
||||
description?: string
|
||||
identity?: string
|
||||
subAgents?: string[]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface SecurityStatus {
|
||||
authMethod: string
|
||||
tokenConfig: { issuer: string; audience: string; refreshTokenDays: number; accessTokenMinutes: number }
|
||||
rateLimit: string
|
||||
passwordPolicy: string
|
||||
cookieConfig: { httpOnly: boolean; secure: boolean; sameSite: string }
|
||||
twoFactorEnabled: boolean
|
||||
passkeyEnabled: boolean
|
||||
}
|
||||
|
||||
export interface ConfigFileInfo {
|
||||
fileName: string
|
||||
size: number
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
export interface ConfigFileDetail extends ConfigFileInfo {
|
||||
content: string
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { AgentInfo } from './agent'
|
||||
|
||||
export interface RuntimeStatus {
|
||||
runtime: string
|
||||
status: 'Online' | 'Degraded' | 'Offline' | 'Unknown'
|
||||
latency?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface ModelStatus {
|
||||
provider: string
|
||||
model: string
|
||||
status: 'Online' | 'Degraded' | 'Offline' | 'Unknown'
|
||||
isLocal: boolean
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface ProjectHealth {
|
||||
online: number
|
||||
offline: number
|
||||
degraded: number
|
||||
unknown: number
|
||||
}
|
||||
|
||||
export interface IncidentInfo {
|
||||
taskId?: string
|
||||
title?: string
|
||||
since?: string
|
||||
}
|
||||
|
||||
export interface RoutingTarget {
|
||||
priority: number
|
||||
provider: string
|
||||
model: string
|
||||
purpose: string
|
||||
status: 'Online' | 'Degraded' | 'Offline' | 'Unknown'
|
||||
detail: string
|
||||
}
|
||||
|
||||
export type TaskState = 'Backlog' | 'In progress' | 'Blocked' | 'Done'
|
||||
export const TASK_STATES: TaskState[] = ['Backlog', 'In progress', 'Blocked', 'Done']
|
||||
|
||||
export interface OperationsSnapshot {
|
||||
generatedAt: string
|
||||
runtime: RuntimeStatus
|
||||
models: ModelStatus[]
|
||||
metrics: {
|
||||
activeAgents: number
|
||||
queuedTasks: number
|
||||
successRate: number
|
||||
incidents: number
|
||||
runtimeHealthy?: boolean
|
||||
lastIncident?: IncidentInfo
|
||||
}
|
||||
projectHealth?: ProjectHealth
|
||||
agents?: AgentInfo[]
|
||||
projects: Array<{ id: string; name: string; status: string; progress: number; updatedAt?: string }>
|
||||
tasks: Array<{
|
||||
id: string
|
||||
title: string
|
||||
state: TaskState
|
||||
priority: string
|
||||
projectId?: string | null
|
||||
updatedAt: string
|
||||
}>
|
||||
activity: Array<{ id?: number; type: string; message: string; at: string }>
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './agent'
|
||||
export * from './config'
|
||||
export * from './dashboard'
|
||||
export * from './project'
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface MemoryFile {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
export interface MemoryDetail extends MemoryFile {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface MemorySearchResult {
|
||||
name: string
|
||||
path: string
|
||||
excerpt: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface DocFile {
|
||||
name: string
|
||||
path: string
|
||||
category: string
|
||||
type: string
|
||||
size: number
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
export interface DocDetail extends DocFile {
|
||||
content: string
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Simple markdown-to-HTML renderer for Nexus.
|
||||
* Handles common constructs without external dependencies.
|
||||
*/
|
||||
export function renderMarkdown(text: string): string {
|
||||
let html = text
|
||||
|
||||
// Escape HTML entities first
|
||||
html = html
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Code blocks (fenced) - process before other inline rules
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang: string, code: string) => {
|
||||
const langAttr = lang ? ` class="language-${lang}"` : ''
|
||||
return `<pre><code${langAttr}>${code.trim()}</code></pre>`
|
||||
})
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---$/gm, '<hr>')
|
||||
|
||||
// Headers
|
||||
html = html.replace(/^###### (.+)$/gm, '<h6>$1</h6>')
|
||||
html = html.replace(/^##### (.+)$/gm, '<h5>$1</h5>')
|
||||
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>')
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
|
||||
// Italic
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
|
||||
// Links [text](url) — block dangerous URL schemes
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match: string, text: string, url: string) => {
|
||||
const lower = url.trim().toLowerCase()
|
||||
if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:')) {
|
||||
return `<a href="#" class="blocked-url" title="Blocked: ${text}">[blocked] ${text}</a>`
|
||||
}
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`
|
||||
})
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>')
|
||||
|
||||
// Paragraphs: wrap remaining lines that aren't already wrapped
|
||||
const lines = html.split('\n')
|
||||
const result: string[] = []
|
||||
let inBlock = false
|
||||
let blockTag = ''
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
if (inBlock) {
|
||||
result.push(`</${blockTag}>`)
|
||||
inBlock = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Already wrapped in a block-level tag
|
||||
if (/^<(h[1-6]|ul|ol|pre|hr|li|p)/.test(trimmed) || /^<\/(ul|ol|pre)>/.test(trimmed) || /^<(ul|ol|pre)>/.test(trimmed)) {
|
||||
if (inBlock) {
|
||||
result.push(`</${blockTag}>`)
|
||||
inBlock = false
|
||||
}
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Closing tag
|
||||
if (/^<\//.test(trimmed)) {
|
||||
if (inBlock) {
|
||||
result.push(`</${blockTag}>`)
|
||||
inBlock = false
|
||||
}
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!inBlock) {
|
||||
inBlock = true
|
||||
blockTag = 'p'
|
||||
result.push(`<${blockTag}>${trimmed}</${blockTag}>`)
|
||||
} else {
|
||||
// Within paragraph
|
||||
result.pop() // Remove previous closing tag
|
||||
const prevContent = result[result.length - 1]?.replace(/^<p>/, '').replace(/<\/p>$/, '') ?? ''
|
||||
result[result.length - 1] = `<p>${prevContent} ${trimmed}</p>`
|
||||
}
|
||||
}
|
||||
|
||||
if (inBlock) {
|
||||
result.push(`</${blockTag}>`)
|
||||
}
|
||||
|
||||
return result.join('\n')
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useOperationsStore } from '../src/stores/operations'
|
||||
|
||||
describe('operations store', () => {
|
||||
it('initializes with fallback data', () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useOperationsStore()
|
||||
expect(store.snapshot.metrics.activeAgents).toBeGreaterThan(0)
|
||||
expect(store.snapshot.runtime.runtime).toBe('OpenClaw')
|
||||
})
|
||||
|
||||
it('has routing targets', () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useOperationsStore()
|
||||
expect(store.routing.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
plugins: [vue(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/health': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user