Initial commit: Nexus Mission Control Platform

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