Initial commit: AMS Frontend - Vue 3 + PrimeVue

This commit is contained in:
FluxKit
2026-02-19 14:03:01 +00:00
commit 263d52141d
50 changed files with 20254 additions and 0 deletions

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=https://api.ams.kronos-soulution.de/api

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ARG APP_VERSION=dev
ARG VITE_API_URL=https://api.ams.kronos-soulution.de/api
ENV VITE_APP_VERSION=$APP_VERSION
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

19
capacitor.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
appId: 'de.agentenbude.ams',
appName: 'AMS',
webDir: 'dist',
server: {
url: 'https://ams.agentenbude.de',
cleartext: false,
},
android: {
buildOptions: {
keystorePath: undefined,
keystoreAlias: undefined,
},
},
}
export default config

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent Management System</title>
<link rel="icon" href="/favicon.ico">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

15
nginx.conf Normal file
View File

@@ -0,0 +1,15 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

2710
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "ams-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@capacitor/android": "^8.0.2",
"@capacitor/cli": "^8.0.2",
"@capacitor/core": "^8.0.2",
"@monaco-editor/loader": "^1.4.0",
"@primevue/themes": "^4.2.5",
"chart.js": "^4.5.1",
"highlight.js": "^11.10.0",
"monaco-editor": "^0.52.2",
"pinia": "^2.3.0",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

27
src/App.vue Normal file
View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import Toast from 'primevue/toast'
import { useAuthStore } from './stores/auth'
import { onMounted } from 'vue'
import { initUpdateCheck } from './utils/appUpdate'
const authStore = useAuthStore()
onMounted(() => {
authStore.loadFromStorage()
// Auto-Update Check (nur in Capacitor/Android App)
initUpdateCheck()
})
</script>
<template>
<Toast position="top-right" />
<router-view />
</template>
<style>
html, body, #app {
margin: 0;
padding: 0;
min-height: 100vh;
}
</style>

64
src/api/index.ts Normal file
View File

@@ -0,0 +1,64 @@
const API_BASE = import.meta.env.VITE_API_URL || '/api'
class ApiClient {
private baseUrl: string
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
private getToken(): string | null {
return localStorage.getItem('ams_token')
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
const token = this.getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Request failed')
}
return data
}
get<T>(path: string): Promise<T> {
return this.request('GET', path)
}
post<T>(path: string, body: unknown): Promise<T> {
return this.request('POST', path, body)
}
put<T>(path: string, body: unknown): Promise<T> {
return this.request('PUT', path, body)
}
patch<T>(path: string, body: unknown): Promise<T> {
return this.request('PATCH', path, body)
}
delete<T>(path: string, params?: Record<string, string>): Promise<T> {
if (params) {
const query = new URLSearchParams(params).toString()
return this.request('DELETE', `${path}?${query}`)
}
return this.request('DELETE', path)
}
}
export const api = new ApiClient(API_BASE)

View File

@@ -0,0 +1,394 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
interface AgentFile {
_id: string
agentId: string
filename: string
content?: string
updatedAt: string
}
interface FileGroup {
label: string
icon: string
files: AgentFile[]
}
const props = defineProps<{
agentId: string
}>()
const toast = useToast()
const files = ref<AgentFile[]>([])
const selectedFile = ref<AgentFile | null>(null)
const fileContent = ref('')
const loading = ref(true)
const fileLoading = ref(false)
const FILE_ICONS: Record<string, string> = {
'SOUL.md': '🧠',
'MEMORY.md': '💾',
'AGENTS.md': '📋',
'USER.md': '👤',
'TOOLS.md': '🔧',
'HEARTBEAT.md': '💓',
'IDENTITY.md': '🪪',
'WORKFLOW.md': '⚙️',
'BOOTSTRAP.md': '🚀',
'README.md': '📖',
'CHECKLIST.md': '✅'
}
function getFileIcon(filename: string): string {
const basename = filename.includes('/') ? filename.split('/').pop()! : filename
return FILE_ICONS[basename] || '📄'
}
function getDisplayName(filename: string): string {
return filename.includes('/') ? filename.split('/').pop()! : filename
}
// Gruppierte Dateien: Root-Dateien + Unterordner
const groupedFiles = computed<FileGroup[]>(() => {
const rootFiles: AgentFile[] = []
const folders: Record<string, AgentFile[]> = {}
for (const file of files.value) {
if (file.filename.includes('/')) {
const folder = file.filename.split('/')[0]
if (!folders[folder]) folders[folder] = []
folders[folder].push(file)
} else {
rootFiles.push(file)
}
}
const groups: FileGroup[] = []
if (rootFiles.length > 0) {
groups.push({ label: 'Workspace', icon: '📁', files: rootFiles })
}
for (const [folder, folderFiles] of Object.entries(folders).sort()) {
const folderIcon = folder === 'memory' ? '📝' : '📂'
groups.push({ label: folder, icon: folderIcon, files: folderFiles.sort((a, b) => b.filename.localeCompare(a.filename)) })
}
return groups
})
async function loadFiles() {
loading.value = true
try {
const data = await api.get<{ files: AgentFile[] }>(`/agents/${props.agentId}/files`)
files.value = data.files
} catch {
files.value = []
} finally {
loading.value = false
}
}
async function loadFileContent(filename: string) {
fileLoading.value = true
try {
// Für Unterordner-Dateien: URL-Parameter verwenden
const encodedName = filename.includes('/')
? `_placeholder?path=${encodeURIComponent(filename)}`
: encodeURIComponent(filename)
const data = await api.get<{ file: AgentFile }>(`/agents/${props.agentId}/files/${encodedName}`)
selectedFile.value = data.file
fileContent.value = data.file.content || ''
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Datei konnte nicht geladen werden', life: 3000 })
} finally {
fileLoading.value = false
}
}
function selectFile(file: AgentFile) {
loadFileContent(file.filename)
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
})
}
watch(() => props.agentId, () => {
selectedFile.value = null
fileContent.value = ''
loadFiles()
})
onMounted(loadFiles)
</script>
<template>
<div class="agent-files">
<div class="files-layout">
<!-- Left: File list -->
<div class="file-list">
<h3><i class="pi pi-file"></i> Workspace Dateien</h3>
<div v-if="loading" class="loading-small">
<i class="pi pi-spin pi-spinner"></i>
</div>
<div v-else-if="files.length === 0" class="empty">
Keine Dateien vorhanden.
</div>
<div v-else class="file-groups">
<div v-for="group in groupedFiles" :key="group.label" class="file-group">
<div class="group-header">
<span>{{ group.icon }} {{ group.label }}</span>
<span class="group-count">{{ group.files.length }}</span>
</div>
<div class="file-items">
<div
v-for="file in group.files"
:key="file._id"
class="file-item"
:class="{ active: selectedFile?.filename === file.filename }"
@click="selectFile(file)"
>
<span class="file-icon">{{ getFileIcon(file.filename) }}</span>
<div class="file-info">
<span class="file-name">{{ getDisplayName(file.filename) }}</span>
<span class="file-date">{{ formatDate(file.updatedAt) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right: Content viewer -->
<div class="file-content">
<template v-if="fileLoading">
<div class="loading-center">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
</div>
</template>
<template v-else-if="selectedFile">
<div class="content-header">
<span>{{ getFileIcon(selectedFile.filename) }} {{ selectedFile.filename }}</span>
</div>
<div class="content-body">
<pre class="markdown-raw"><code>{{ fileContent }}</code></pre>
</div>
</template>
<template v-else>
<div class="empty-center">
<i class="pi pi-file" style="font-size: 3rem; opacity: 0.3;"></i>
<p>Datei auswählen</p>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.agent-files {
height: 100%;
}
.files-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 0;
height: 500px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
overflow: hidden;
}
.file-list {
background: rgba(255, 255, 255, 0.03);
border-right: 1px solid rgba(255, 255, 255, 0.1);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 1rem;
min-height: 0;
}
.file-list h3 {
margin: 0 0 1rem;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
display: flex;
align-items: center;
gap: 0.5rem;
}
.file-groups {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.05em;
}
.group-count {
background: rgba(255, 255, 255, 0.1);
padding: 0.1rem 0.4rem;
border-radius: 10px;
font-size: 0.7rem;
}
.file-items {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.file-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.file-item.active {
background: rgba(99, 102, 241, 0.2);
border: 1px solid rgba(99, 102, 241, 0.3);
}
.file-icon {
font-size: 1.1rem;
flex-shrink: 0;
}
.file-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.file-name {
font-size: 0.85rem;
font-weight: 500;
color: #e6edf3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-date {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.4);
}
.file-content {
background: #0d1117;
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.content-header {
padding: 0.625rem 1rem;
background: #161b22;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.9rem;
font-weight: 500;
color: #e6edf3;
}
.content-body {
flex: 1;
overflow: auto;
-webkit-overflow-scrolling: touch;
padding: 0;
min-height: 0;
}
.markdown-raw {
margin: 0;
padding: 1rem;
background: transparent;
color: #e6edf3;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.85rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.markdown-raw code {
font-family: inherit;
}
.loading-small {
text-align: center;
padding: 1rem;
color: rgba(255, 255, 255, 0.5);
}
.loading-center, .empty-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.4);
gap: 0.5rem;
}
.empty {
color: rgba(255, 255, 255, 0.4);
font-size: 0.85rem;
font-style: italic;
padding: 0.5rem 0;
}
@media (max-width: 768px) {
.files-layout {
grid-template-columns: 1fr;
height: auto;
overflow: visible;
}
.file-list {
border-right: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
max-height: 250px;
}
.file-content {
min-height: 200px;
}
.content-body {
max-height: 60vh;
}
}
</style>

View File

@@ -0,0 +1,437 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
interface Attachment {
_id: string
originalName: string
mimeType: string
size: number
createdAt: string
data?: string
}
const props = withDefaults(defineProps<{
parentType: 'task' | 'comment' | 'project' | 'agent'
parentId: string
enablePaste?: boolean
}>(), {
enablePaste: false
})
const emit = defineEmits<{
(e: 'update'): void
}>()
const toast = useToast()
const authStore = useAuthStore()
const attachments = ref<Attachment[]>([])
const loading = ref(false)
const uploading = ref(false)
// Lightbox state
const showLightbox = ref(false)
const lightboxImage = ref('')
const lightboxName = ref('')
async function loadAttachments() {
loading.value = true
try {
const res = await api.get<{ attachments: Attachment[] }>(`/attachments/${props.parentType}/${props.parentId}`)
attachments.value = res.attachments
} catch (err: any) {
console.error('Failed to load attachments:', err)
} finally {
loading.value = false
}
}
async function uploadFile(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files?.length) return
const file = input.files[0]
if (file.size > 5 * 1024 * 1024) {
toast.add({ severity: 'warn', summary: 'Zu groß', detail: 'Max. 5MB erlaubt', life: 5000 })
return
}
uploading.value = true
try {
const reader = new FileReader()
reader.onload = async () => {
const base64 = (reader.result as string).split(',')[1]
await api.post(`/attachments/${props.parentType}/${props.parentId}`, {
filename: file.name,
data: base64,
mimeType: file.type
})
await loadAttachments()
emit('update')
toast.add({ severity: 'success', summary: 'Hochgeladen', detail: file.name, life: 3000 })
}
reader.readAsDataURL(file)
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
} finally {
uploading.value = false
input.value = ''
}
}
// Paste handler for clipboard images
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.type.startsWith('image/')) {
event.preventDefault()
const file = item.getAsFile()
if (!file) continue
if (file.size > 5 * 1024 * 1024) {
toast.add({ severity: 'warn', summary: 'Zu groß', detail: 'Max. 5MB erlaubt', life: 5000 })
return
}
uploading.value = true
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `clipboard-${timestamp}.png`
try {
const reader = new FileReader()
reader.onload = async () => {
const base64 = (reader.result as string).split(',')[1]
await api.post(`/attachments/${props.parentType}/${props.parentId}`, {
filename,
data: base64,
mimeType: file.type || 'image/png'
})
await loadAttachments()
emit('update')
toast.add({ severity: 'success', summary: 'Eingefügt', detail: 'Bild aus Zwischenablage', life: 3000 })
uploading.value = false
}
reader.readAsDataURL(file)
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
uploading.value = false
}
break
}
}
}
async function deleteAttachment(id: string, name: string) {
if (!confirm(`"${name}" löschen?`)) return
try {
await api.delete(`/attachments/${id}`)
attachments.value = attachments.value.filter(a => a._id !== id)
emit('update')
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
}
}
async function openAttachment(att: Attachment) {
// For images, show in lightbox
if (att.mimeType.startsWith('image/')) {
try {
const res = await api.get<{ attachment: { data: string; mimeType: string } }>(`/attachments/file/${att._id}`)
lightboxImage.value = `data:${res.attachment.mimeType};base64,${res.attachment.data}`
lightboxName.value = att.originalName
showLightbox.value = true
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
}
} else {
// For other files, download
await downloadAttachment(att._id, att.originalName)
}
}
async function downloadAttachment(id: string, name: string) {
try {
const res = await api.get<{ attachment: { data: string; mimeType: string } }>(`/attachments/file/${id}`)
const link = document.createElement('a')
link.href = `data:${res.attachment.mimeType};base64,${res.attachment.data}`
link.download = name
link.click()
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
}
}
function closeLightbox() {
showLightbox.value = false
lightboxImage.value = ''
}
function formatSize(bytes: number) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function isImage(mimeType: string) {
return mimeType.startsWith('image/')
}
onMounted(() => {
loadAttachments()
// Only register paste handler if explicitly enabled (to avoid duplicates)
if (props.enablePaste) {
document.addEventListener('paste', handlePaste)
}
})
onUnmounted(() => {
if (props.enablePaste) {
document.removeEventListener('paste', handlePaste)
}
})
// Expose reload for parent components
defineExpose({ reload: loadAttachments })
</script>
<template>
<div class="attachment-list">
<div class="attachment-header">
<h4><i class="pi pi-paperclip"></i> Anhänge ({{ attachments.length }})</h4>
<label class="upload-btn">
<input type="file" @change="uploadFile" hidden />
<i class="pi pi-upload"></i>
{{ uploading ? 'Lädt...' : 'Hochladen' }}
</label>
</div>
<div v-if="loading" class="loading-small">
<i class="pi pi-spin pi-spinner"></i>
</div>
<div v-else-if="attachments.length === 0" class="empty-text">
Keine Anhänge{{ enablePaste ? ' Strg+V für Zwischenablage' : '' }}
</div>
<div v-else class="attachments-grid">
<div
v-for="att in attachments"
:key="att._id"
class="attachment-item"
:class="{ 'is-image': isImage(att.mimeType) }"
@click="openAttachment(att)"
>
<div class="att-icon">
<i class="pi" :class="isImage(att.mimeType) ? 'pi-image' : 'pi-file'"></i>
</div>
<div class="att-info">
<span class="att-name">{{ att.originalName }}</span>
<span class="att-size">{{ formatSize(att.size) }}</span>
</div>
<div class="att-actions">
<button @click.stop="downloadAttachment(att._id, att.originalName)" class="icon-btn" title="Download">
<i class="pi pi-download"></i>
</button>
<button @click.stop="deleteAttachment(att._id, att.originalName)" class="icon-btn delete" title="Löschen">
<i class="pi pi-trash"></i>
</button>
</div>
</div>
</div>
<!-- Lightbox -->
<Teleport to="body">
<div v-if="showLightbox" class="lightbox" @click="closeLightbox">
<div class="lightbox-content" @click.stop>
<button class="lightbox-close" @click="closeLightbox">
<i class="pi pi-times"></i>
</button>
<img :src="lightboxImage" :alt="lightboxName" />
<div class="lightbox-caption">{{ lightboxName }}</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.attachment-list {
margin-top: 1rem;
}
.attachment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.attachment-header h4 {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
gap: 0.5rem;
}
.upload-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.375rem 0.75rem;
background: rgba(99, 102, 241, 0.2);
border-radius: 6px;
color: #818cf8;
cursor: pointer;
font-size: 0.8rem;
}
.upload-btn:hover {
background: rgba(99, 102, 241, 0.3);
}
.loading-small {
padding: 1rem;
text-align: center;
color: rgba(255, 255, 255, 0.5);
}
.empty-text {
color: rgba(255, 255, 255, 0.4);
font-size: 0.85rem;
font-style: italic;
padding: 0.5rem 0;
}
.attachments-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.attachment-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
cursor: pointer;
transition: background 0.15s;
}
.attachment-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.attachment-item.is-image {
border-left: 3px solid #22c55e;
}
.att-icon {
color: rgba(255, 255, 255, 0.5);
}
.att-info {
flex: 1;
min-width: 0;
}
.att-name {
display: block;
color: #fff;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.att-size {
display: block;
color: rgba(255, 255, 255, 0.4);
font-size: 0.75rem;
}
.att-actions {
display: flex;
gap: 0.25rem;
}
.icon-btn {
padding: 0.375rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
border-radius: 4px;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.icon-btn.delete:hover {
color: #f87171;
background: rgba(239, 68, 68, 0.2);
}
/* Lightbox */
.lightbox {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 2rem;
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
}
.lightbox-content img {
max-width: 100%;
max-height: 85vh;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.lightbox-close {
position: absolute;
top: -40px;
right: 0;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.lightbox-caption {
text-align: center;
margin-top: 1rem;
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useToast } from 'primevue/usetoast'
const emit = defineEmits<{
(e: 'transcribed', text: string): void
}>()
const toast = useToast()
const recording = ref(false)
const transcribing = ref(false)
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
async function toggleRecording() {
if (recording.value) {
stopRecording()
} else {
await startRecording()
}
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' })
audioChunks = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) audioChunks.push(event.data)
}
mediaRecorder.onstop = async () => {
stream.getTracks().forEach(track => track.stop())
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' })
await transcribeAudio(audioBlob)
}
mediaRecorder.start()
recording.value = true
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Mikrofon-Zugriff verweigert', life: 3000 })
}
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop()
recording.value = false
}
async function transcribeAudio(blob: Blob) {
transcribing.value = true
try {
const formData = new FormData()
formData.append('audio', blob, 'recording.webm')
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
const token = localStorage.getItem('ams_token')
const response = await fetch(`${apiBase}/transcribe`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
})
const data = await response.json()
if (data.error) {
toast.add({ severity: 'error', summary: 'Transkription', detail: data.error, life: 5000 })
return
}
if (data.text) {
emit('transcribed', data.text)
}
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Transkription fehlgeschlagen', life: 3000 })
} finally {
transcribing.value = false
}
}
</script>
<template>
<button
class="stt-btn"
:class="{ recording, transcribing }"
@click="toggleRecording"
:disabled="transcribing"
:title="recording ? 'Aufnahme stoppen' : transcribing ? 'Transkribiert...' : 'Spracheingabe'"
type="button"
>
<i v-if="transcribing" class="pi pi-spin pi-spinner"></i>
<i v-else :class="recording ? 'pi pi-stop-circle' : 'pi pi-microphone'"></i>
</button>
</template>
<style scoped>
.stt-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.stt-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.stt-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.stt-btn.recording {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.4);
color: #fca5a5;
animation: pulse-glow 1.5s infinite;
}
.stt-btn.transcribing {
color: #a5b4fc;
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
}
</style>

View File

@@ -0,0 +1,261 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
interface ChangelogEntry {
_id: string
taskId: string
field: string
oldValue: unknown
newValue: unknown
changedBy: string
changedByName?: string
changedAt: string
}
interface Agent {
_id: string
name: string
emoji: string
}
interface Label {
_id: string
name: string
color: string
}
interface Project {
_id: string
name: string
}
const props = defineProps<{
taskId: string
agents: Agent[]
labels: Label[]
projects: Project[]
}>()
const entries = ref<ChangelogEntry[]>([])
const loading = ref(true)
const STATUS_LABELS: Record<string, string> = {
backlog: 'Backlog',
todo: 'To Do',
in_progress: 'In Progress',
review: 'Review',
done: 'Done'
}
const PRIORITY_LABELS: Record<string, string> = {
low: 'Niedrig',
medium: 'Mittel',
high: 'Hoch',
urgent: 'Dringend'
}
const FIELD_LABELS: Record<string, string> = {
title: 'Titel',
description: 'Beschreibung',
status: 'Status',
priority: 'Priorität',
assignee: 'Agent',
project: 'Projekt',
labels: 'Labels',
dueDate: 'Fälligkeitsdatum',
commits: 'GitLab Commit'
}
function formatFieldName(field: string): string {
return FIELD_LABELS[field] || field
}
function formatValue(field: string, value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
switch (field) {
case 'status':
return STATUS_LABELS[value as string] || (value as string)
case 'priority':
return PRIORITY_LABELS[value as string] || (value as string)
case 'assignee': {
const agent = props.agents.find(a => a._id === value)
return agent ? `${agent.emoji} ${agent.name}` : (value as string)
}
case 'project': {
const project = props.projects.find(p => p._id === value)
return project ? project.name : (value as string)
}
case 'labels': {
const ids = value as string[]
if (!ids?.length) return '—'
return ids.map(id => {
const label = props.labels.find(l => l._id === id)
return label ? label.name : id
}).join(', ')
}
case 'dueDate':
return value ? new Date(value as string).toLocaleDateString('de-DE') : '—'
case 'description':
return (value as string).length > 50
? (value as string).substring(0, 50) + '…'
: (value as string)
default:
return String(value)
}
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function getUserName(entry: ChangelogEntry): string {
if (entry.changedByName) return entry.changedByName
const agent = props.agents.find(a => a._id === entry.changedBy)
if (agent) return `${agent.emoji} ${agent.name}`
return entry.changedBy.substring(0, 8) + '…'
}
async function loadChangelog() {
loading.value = true
try {
const res = await api.get<{ changelog: ChangelogEntry[] }>(`/tasks/${props.taskId}/changelog`)
entries.value = res.changelog
} catch {
entries.value = []
} finally {
loading.value = false
}
}
onMounted(loadChangelog)
</script>
<template>
<div class="changelog">
<h3><i class="pi pi-history"></i> Änderungslog</h3>
<div v-if="loading" class="loading-small">
<i class="pi pi-spin pi-spinner"></i> Lade
</div>
<div v-else-if="entries.length === 0" class="empty">
Noch keine Änderungen protokolliert.
</div>
<div v-else class="changelog-list">
<div v-for="entry in entries" :key="entry._id" class="changelog-entry">
<div class="entry-header">
<span class="entry-user">{{ getUserName(entry) }}</span>
<span class="entry-date">{{ formatDate(entry.changedAt) }}</span>
</div>
<div class="entry-change">
<span class="field-name">{{ formatFieldName(entry.field) }}</span>
<span class="old-value">{{ formatValue(entry.field, entry.oldValue) }}</span>
<i class="pi pi-arrow-right arrow"></i>
<span class="new-value">{{ formatValue(entry.field, entry.newValue) }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.changelog {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.changelog h3 {
margin: 0 0 1rem;
font-size: 1rem;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
gap: 0.5rem;
}
.loading-small {
color: rgba(255, 255, 255, 0.5);
font-size: 0.875rem;
padding: 0.5rem 0;
}
.empty {
color: rgba(255, 255, 255, 0.4);
font-size: 0.875rem;
font-style: italic;
padding: 0.5rem 0;
}
.changelog-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 400px;
overflow-y: auto;
}
.changelog-entry {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 0.625rem 0.875rem;
}
.entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.entry-user {
font-size: 0.8rem;
font-weight: 500;
color: #818cf8;
}
.entry-date {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
}
.entry-change {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
.field-name {
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
min-width: 80px;
}
.old-value {
color: rgba(239, 68, 68, 0.8);
text-decoration: line-through;
}
.arrow {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.3);
}
.new-value {
color: rgba(34, 197, 94, 0.9);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,620 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
interface TaskCommit {
_id: string
taskId: string
commitSha: string
shortId: string
gitlabProjectId: number
gitlabProjectPath: string
commitTitle: string
commitAuthor: string
commitDate: string
commitUrl: string
linkedBy: string
linkedByName: string
linkedAt: string
}
interface GitLabProject {
projectId: number
path: string
url: string
name: string
}
interface GitLabCommit {
id: string
shortId: string
title: string
message: string
author: string
authorEmail: string
date: string
webUrl: string
}
interface Project {
_id: string
name: string
gitlabProjects?: GitLabProject[]
gitlabProjectId?: number
gitlabPath?: string
gitlabUrl?: string
}
const props = defineProps<{
taskId: string
projectId: string
}>()
const toast = useToast()
const commits = ref<TaskCommit[]>([])
const loading = ref(true)
const showLinkDialog = ref(false)
// Link-Dialog State
const availableProjects = ref<GitLabProject[]>([])
const selectedGitLabProject = ref<number | null>(null)
const gitlabCommits = ref<GitLabCommit[]>([])
const loadingCommits = ref(false)
const searchQuery = ref('')
const commitPage = ref(1)
const filteredCommits = computed(() => {
if (!searchQuery.value) return gitlabCommits.value
const q = searchQuery.value.toLowerCase()
return gitlabCommits.value.filter(c =>
c.title.toLowerCase().includes(q) ||
c.shortId.toLowerCase().includes(q) ||
c.author.toLowerCase().includes(q)
)
})
const alreadyLinkedShas = computed(() => new Set(commits.value.map(c => c.commitSha)))
async function loadCommits() {
loading.value = true
try {
const res = await api.get<{ commits: TaskCommit[] }>(`/tasks/${props.taskId}/commits`)
commits.value = res.commits
} catch {
commits.value = []
} finally {
loading.value = false
}
}
async function loadProjectGitLabInfo() {
if (!props.projectId) return
try {
const res = await api.get<{ projects: Project[] }>('/tasks/projects/list')
const project = res.projects.find(p => p._id === props.projectId)
if (!project) return
const projects: GitLabProject[] = []
// Neue Multi-Projekt-Struktur
if (project.gitlabProjects?.length) {
projects.push(...project.gitlabProjects)
}
// Legacy Single-Projekt
if (project.gitlabProjectId && !projects.some(p => p.projectId === project.gitlabProjectId)) {
projects.push({
projectId: project.gitlabProjectId,
path: project.gitlabPath || '',
url: project.gitlabUrl || '',
name: project.gitlabPath?.split('/').pop() || 'GitLab Projekt',
})
}
availableProjects.value = projects
if (projects.length === 1) {
selectedGitLabProject.value = projects[0].projectId
}
} catch {
availableProjects.value = []
}
}
async function loadGitLabCommits() {
if (!selectedGitLabProject.value) return
loadingCommits.value = true
try {
const res = await api.get<GitLabCommit[]>(
`/gitlab/projects/${selectedGitLabProject.value}/commits?per_page=50&page=${commitPage.value}`
)
gitlabCommits.value = res
} catch {
gitlabCommits.value = []
toast.add({ severity: 'error', summary: 'Fehler', detail: 'GitLab-Commits konnten nicht geladen werden', life: 5000 })
} finally {
loadingCommits.value = false
}
}
async function linkCommit(commit: GitLabCommit) {
const project = availableProjects.value.find(p => p.projectId === selectedGitLabProject.value)
try {
await api.post(`/tasks/${props.taskId}/commits`, {
commitSha: commit.id,
shortId: commit.shortId,
gitlabProjectId: selectedGitLabProject.value,
gitlabProjectPath: project?.path || '',
commitTitle: commit.title,
commitAuthor: commit.author,
commitDate: commit.date,
commitUrl: commit.webUrl,
})
toast.add({ severity: 'success', summary: 'Verknüpft', detail: `Commit ${commit.shortId} verknüpft`, life: 3000 })
await loadCommits()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
}
}
async function unlinkCommit(commitLink: TaskCommit) {
if (!confirm(`Commit ${commitLink.shortId} entfernen?`)) return
try {
await api.delete(`/tasks/${props.taskId}/commits/${commitLink._id}`)
toast.add({ severity: 'success', summary: 'Entfernt', detail: 'Commit-Verknüpfung entfernt', life: 3000 })
await loadCommits()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
}
}
function openLinkDialog() {
showLinkDialog.value = true
searchQuery.value = ''
gitlabCommits.value = []
commitPage.value = 1
if (selectedGitLabProject.value) {
loadGitLabCommits()
}
}
function onProjectChange() {
commitPage.value = 1
gitlabCommits.value = []
searchQuery.value = ''
if (selectedGitLabProject.value) {
loadGitLabCommits()
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
onMounted(() => {
loadCommits()
loadProjectGitLabInfo()
})
</script>
<template>
<div class="task-commits">
<div class="section-header">
<h3><i class="pi pi-code"></i> GitLab Commits</h3>
<button
v-if="availableProjects.length > 0"
@click="openLinkDialog"
class="add-commit-btn"
>
<i class="pi pi-plus"></i> Verknüpfen
</button>
</div>
<div v-if="loading" class="loading-small">
<i class="pi pi-spin pi-spinner"></i> Lade
</div>
<div v-else-if="commits.length === 0" class="empty">
Keine Commits verknüpft.
</div>
<div v-else class="commits-list">
<div v-for="commit in commits" :key="commit._id" class="commit-entry">
<div class="commit-main">
<a :href="commit.commitUrl" target="_blank" rel="noopener" class="commit-sha">
{{ commit.shortId }}
</a>
<span class="commit-title">{{ commit.commitTitle }}</span>
</div>
<div class="commit-meta">
<span class="commit-author">{{ commit.commitAuthor }}</span>
<span class="commit-date">{{ formatDate(commit.commitDate) }}</span>
<span class="commit-project">{{ commit.gitlabProjectPath }}</span>
<button @click="unlinkCommit(commit)" class="unlink-btn" title="Entfernen">
<i class="pi pi-times"></i>
</button>
</div>
</div>
</div>
<!-- Link Dialog -->
<div v-if="showLinkDialog" class="dialog-overlay" @click.self="showLinkDialog = false">
<div class="dialog">
<div class="dialog-header">
<h3><i class="pi pi-link"></i> Commit verknüpfen</h3>
<button @click="showLinkDialog = false" class="close-btn">
<i class="pi pi-times"></i>
</button>
</div>
<div class="dialog-body">
<!-- GitLab Projekt auswählen -->
<div v-if="availableProjects.length > 1" class="form-group">
<label>GitLab Projekt</label>
<select v-model="selectedGitLabProject" @change="onProjectChange">
<option :value="null" disabled>Projekt wählen</option>
<option v-for="p in availableProjects" :key="p.projectId" :value="p.projectId">
{{ p.name }} ({{ p.path }})
</option>
</select>
</div>
<!-- Suche -->
<div class="form-group">
<label>Suche</label>
<input
v-model="searchQuery"
type="text"
placeholder="Commit-Titel, SHA oder Autor…"
/>
</div>
<!-- Commits-Liste -->
<div v-if="loadingCommits" class="loading-small">
<i class="pi pi-spin pi-spinner"></i> Lade Commits
</div>
<div v-else-if="filteredCommits.length === 0 && selectedGitLabProject" class="empty">
Keine Commits gefunden.
</div>
<div v-else class="dialog-commits-list">
<div
v-for="commit in filteredCommits"
:key="commit.id"
class="dialog-commit-entry"
:class="{ linked: alreadyLinkedShas.has(commit.id) }"
>
<div class="commit-info">
<span class="commit-sha-small">{{ commit.shortId }}</span>
<span class="commit-title-small">{{ commit.title }}</span>
</div>
<div class="commit-info-meta">
<span>{{ commit.author }}</span>
<span>{{ formatDate(commit.date) }}</span>
</div>
<button
v-if="!alreadyLinkedShas.has(commit.id)"
@click="linkCommit(commit)"
class="link-btn"
>
<i class="pi pi-link"></i>
</button>
<span v-else class="already-linked">
<i class="pi pi-check"></i>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.task-commits {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h3 {
margin: 0;
font-size: 1rem;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
gap: 0.5rem;
}
.add-commit-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
background: rgba(99, 102, 241, 0.2);
border: 1px solid rgba(99, 102, 241, 0.4);
border-radius: 6px;
color: #818cf8;
cursor: pointer;
font-size: 0.8rem;
}
.add-commit-btn:hover {
background: rgba(99, 102, 241, 0.3);
}
.loading-small {
color: rgba(255, 255, 255, 0.5);
font-size: 0.875rem;
padding: 0.5rem 0;
}
.empty {
color: rgba(255, 255, 255, 0.4);
font-size: 0.875rem;
font-style: italic;
padding: 0.5rem 0;
}
.commits-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.commit-entry {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 0.625rem 0.875rem;
}
.commit-main {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.commit-sha {
font-family: monospace;
font-size: 0.8rem;
color: #818cf8;
text-decoration: none;
background: rgba(99, 102, 241, 0.15);
padding: 0.125rem 0.375rem;
border-radius: 4px;
flex-shrink: 0;
}
.commit-sha:hover {
background: rgba(99, 102, 241, 0.3);
}
.commit-title {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
}
.commit-project {
font-family: monospace;
font-size: 0.7rem;
}
.unlink-btn {
padding: 0.2rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.3);
cursor: pointer;
border-radius: 4px;
margin-left: auto;
}
.unlink-btn:hover {
color: #f87171;
background: rgba(239, 68, 68, 0.2);
}
/* Dialog */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.dialog {
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
width: 90%;
max-width: 700px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.dialog-header h3 {
margin: 0;
font-size: 1rem;
color: #fff;
display: flex;
align-items: center;
gap: 0.5rem;
}
.close-btn {
padding: 0.375rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
border-radius: 4px;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.dialog-body {
padding: 1.25rem;
overflow-y: auto;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.4rem;
font-size: 0.8rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.6rem 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #6366f1;
}
.form-group select option {
background: #1a1a2e;
}
.dialog-commits-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
max-height: 400px;
overflow-y: auto;
}
.dialog-commit-entry {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.25rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
padding: 0.5rem 0.75rem;
align-items: center;
}
.dialog-commit-entry.linked {
opacity: 0.5;
}
.commit-info {
display: flex;
align-items: center;
gap: 0.5rem;
grid-column: 1;
min-width: 0;
}
.commit-sha-small {
font-family: monospace;
font-size: 0.75rem;
color: #818cf8;
background: rgba(99, 102, 241, 0.15);
padding: 0.1rem 0.3rem;
border-radius: 3px;
flex-shrink: 0;
}
.commit-title-small {
font-size: 0.825rem;
color: rgba(255, 255, 255, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-info-meta {
grid-column: 1;
display: flex;
gap: 0.75rem;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.4);
}
.link-btn {
grid-column: 2;
grid-row: 1 / 3;
padding: 0.4rem 0.6rem;
background: rgba(99, 102, 241, 0.2);
border: 1px solid rgba(99, 102, 241, 0.4);
border-radius: 6px;
color: #818cf8;
cursor: pointer;
align-self: center;
}
.link-btn:hover {
background: rgba(99, 102, 241, 0.4);
color: #fff;
}
.already-linked {
grid-column: 2;
grid-row: 1 / 3;
color: rgba(34, 197, 94, 0.6);
padding: 0.4rem;
align-self: center;
}
</style>

View File

@@ -0,0 +1,812 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
import Dialog from 'primevue/dialog'
import MultiSelect from 'primevue/multiselect'
import AttachmentList from './AttachmentList.vue'
import CommentList from './CommentList.vue'
import TaskChangelog from './TaskChangelog.vue'
import SpeechToText from './SpeechToText.vue'
interface Reminder {
enabled: boolean
datetime: string
interval: 'once' | 'daily' | 'hourly' | 'minutes'
intervalValue?: number
lastNotified?: string
}
interface Task {
_id: string
title: string
description?: string
status: string
priority: string
assignee?: string
project?: string
labels: string[]
reminder?: Reminder
}
interface Agent {
_id: string
name: string
emoji: string
}
interface Project {
_id: string
name: string
color: string
}
interface Label {
_id: string
name: string
color: string
}
const props = defineProps<{
taskId: string | null
visible: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'saved'): void
}>()
const toast = useToast()
const task = ref<Task | null>(null)
const editForm = ref({
title: '',
description: '',
status: 'todo',
priority: 'medium',
assignee: '',
project: '',
labels: [] as string[]
})
const agents = ref<Agent[]>([])
const projects = ref<Project[]>([])
const labels = ref<Label[]>([])
const loading = ref(false)
const saving = ref(false)
// Reminder state
const showReminderForm = ref(false)
const reminderDate = ref('')
const reminderTime = ref('')
const reminderInterval = ref<'once' | 'daily' | 'hourly' | 'minutes'>('once')
const reminderMinutes = ref(30)
const savingReminder = ref(false)
const dialogVisible = computed({
get: () => props.visible,
set: (val: boolean) => emit('update:visible', val)
})
const statusOptions = [
{ value: 'backlog', label: 'Backlog', color: '#64748b' },
{ value: 'todo', label: 'To Do', color: '#3b82f6' },
{ value: 'in_progress', label: 'In Progress', color: '#f59e0b' },
{ value: 'review', label: 'Review', color: '#8b5cf6' },
{ value: 'done', label: 'Done', color: '#22c55e' }
]
const priorityOptions = [
{ value: 'low', label: 'Niedrig', color: '#64748b' },
{ value: 'medium', label: 'Mittel', color: '#3b82f6' },
{ value: 'high', label: 'Hoch', color: '#f59e0b' },
{ value: 'urgent', label: 'Dringend', color: '#ef4444' }
]
const intervalOptions = [
{ value: 'once', label: 'Einmalig' },
{ value: 'daily', label: 'Täglich' },
{ value: 'hourly', label: 'Stündlich' },
{ value: 'minutes', label: 'Alle X Minuten' }
]
const hasReminder = computed(() => task.value?.reminder?.enabled)
const reminderDisplay = computed(() => {
if (!task.value?.reminder) return null
const r = task.value.reminder
const dt = new Date(r.datetime)
const dateStr = dt.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const timeStr = dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
let intervalStr = ''
switch (r.interval) {
case 'once': intervalStr = 'Einmalig'; break
case 'daily': intervalStr = 'Täglich'; break
case 'hourly': intervalStr = 'Stündlich'; break
case 'minutes': intervalStr = `Alle ${r.intervalValue} Min`; break
}
return { date: dateStr, time: timeStr, interval: intervalStr, enabled: r.enabled }
})
const statusOrder = ['backlog', 'todo', 'in_progress', 'review', 'done']
function getNextStatus(current: string): string | null {
const idx = statusOrder.indexOf(current)
if (idx < 0 || idx >= statusOrder.length - 1) return null
return statusOrder[idx + 1]
}
const nextStatusLabel = computed(() => {
const next = getNextStatus(editForm.value.status)
if (!next) return null
return statusOptions.find(o => o.value === next)?.label || next
})
async function advanceStatus() {
if (!props.taskId) return
const next = getNextStatus(editForm.value.status)
if (!next) return
try {
await api.put(`/tasks/${props.taskId}`, { status: next })
editForm.value.status = next
if (task.value) task.value.status = next
toast.add({ severity: 'success', summary: 'Status geändert', detail: `${statusOptions.find(o => o.value === next)?.label}`, life: 2000 })
emit('saved')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
}
}
function getStatusColor(status: string) {
return statusOptions.find(s => s.value === status)?.color || '#64748b'
}
function getPriorityColor(priority: string) {
return priorityOptions.find(p => p.value === priority)?.color || '#64748b'
}
async function loadTask() {
if (!props.taskId) return
loading.value = true
try {
const [taskRes, agentsRes, projectsRes, labelsRes] = await Promise.all([
api.get<{ task: Task }>(`/tasks/${props.taskId}`),
api.get<{ agents: Agent[] }>('/agents'),
api.get<{ projects: Project[] }>('/tasks/projects/list'),
api.get<{ labels: Label[] }>('/labels')
])
task.value = taskRes.task
agents.value = agentsRes.agents
projects.value = projectsRes.projects
labels.value = labelsRes.labels
editForm.value = {
title: taskRes.task.title,
description: taskRes.task.description || '',
status: taskRes.task.status,
priority: taskRes.task.priority,
assignee: taskRes.task.assignee || '',
project: taskRes.task.project || '',
labels: taskRes.task.labels || []
}
if (taskRes.task.reminder) {
const dt = new Date(taskRes.task.reminder.datetime)
reminderDate.value = dt.toISOString().split('T')[0]
reminderTime.value = dt.toTimeString().slice(0, 5)
reminderInterval.value = taskRes.task.reminder.interval
reminderMinutes.value = taskRes.task.reminder.intervalValue || 30
}
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Task nicht gefunden', life: 5000 })
emit('update:visible', false)
} finally {
loading.value = false
}
}
async function saveTask() {
if (!props.taskId || !editForm.value.title.trim()) {
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Titel ist erforderlich', life: 3000 })
return
}
saving.value = true
try {
await api.put(`/tasks/${props.taskId}`, editForm.value)
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Task aktualisiert', life: 3000 })
if (task.value) {
task.value = { ...task.value, ...editForm.value }
}
emit('saved')
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Speichern fehlgeschlagen'
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
} finally {
saving.value = false
}
}
async function saveReminder() {
if (!props.taskId || !reminderDate.value || !reminderTime.value) {
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Datum und Uhrzeit sind erforderlich', life: 3000 })
return
}
savingReminder.value = true
try {
const datetime = new Date(`${reminderDate.value}T${reminderTime.value}`)
await api.patch(`/tasks/${props.taskId}/reminder`, {
datetime: datetime.toISOString(),
interval: reminderInterval.value,
intervalValue: reminderInterval.value === 'minutes' ? reminderMinutes.value : undefined
})
if (task.value) {
task.value.reminder = {
enabled: true,
datetime: datetime.toISOString(),
interval: reminderInterval.value,
intervalValue: reminderMinutes.value
}
}
showReminderForm.value = false
toast.add({ severity: 'success', summary: 'Reminder gesetzt', detail: 'Du wirst erinnert', life: 3000 })
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Speichern fehlgeschlagen'
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
} finally {
savingReminder.value = false
}
}
async function deleteReminder() {
if (!props.taskId) return
try {
await api.delete(`/tasks/${props.taskId}/reminder`)
if (task.value) {
task.value.reminder = undefined
}
toast.add({ severity: 'success', summary: 'Entfernt', detail: 'Reminder gelöscht', life: 3000 })
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Löschen fehlgeschlagen'
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
}
}
function openReminderForm() {
if (!reminderDate.value) {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
reminderDate.value = tomorrow.toISOString().split('T')[0]
reminderTime.value = '09:00'
}
showReminderForm.value = true
}
watch(() => props.taskId, (newId) => {
if (newId && props.visible) {
showReminderForm.value = false
loadTask()
}
})
watch(() => props.visible, (vis) => {
if (vis && props.taskId) {
showReminderForm.value = false
loadTask()
}
})
</script>
<template>
<Dialog
v-model:visible="dialogVisible"
:header="task?.title || 'Task bearbeiten'"
:modal="true"
:style="{ width: '90vw', maxWidth: '1100px' }"
:breakpoints="{ '768px': '95vw' }"
:contentStyle="{ padding: 0 }"
:dismissableMask="true"
>
<div v-if="loading" class="dialog-loading">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
</div>
<div v-else-if="task" class="task-dialog-layout">
<!-- LEFT: Edit Form -->
<div class="edit-panel">
<div class="form-group">
<label>Titel</label>
<div class="input-with-stt">
<input v-model="editForm.title" type="text" placeholder="Task-Titel" />
<SpeechToText @transcribed="(t: string) => editForm.title += (editForm.title ? ' ' : '') + t" />
</div>
</div>
<div class="form-group">
<label>Beschreibung</label>
<div class="input-with-stt">
<textarea v-model="editForm.description" rows="3" placeholder="Beschreibung (optional)"></textarea>
<SpeechToText @transcribed="(t: string) => editForm.description += (editForm.description ? ' ' : '') + t" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Status</label>
<select v-model="editForm.status">
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<div class="status-indicator" :style="{ background: getStatusColor(editForm.status) }"></div>
</div>
<div class="form-group">
<label>Priorität</label>
<select v-model="editForm.priority">
<option v-for="opt in priorityOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<div class="status-indicator" :style="{ background: getPriorityColor(editForm.priority) }"></div>
</div>
</div>
<div class="form-group">
<label>Agent</label>
<select v-model="editForm.assignee">
<option value="">Nicht zugewiesen</option>
<option v-for="agent in agents" :key="agent._id" :value="agent._id">
{{ agent.emoji }} {{ agent.name }}
</option>
</select>
</div>
<div class="form-group">
<label>Projekt</label>
<select v-model="editForm.project">
<option value="">Kein Projekt</option>
<option v-for="project in projects" :key="project._id" :value="project._id">
{{ project.name }}
</option>
</select>
</div>
<div class="form-group">
<label>Labels</label>
<MultiSelect
v-model="editForm.labels"
:options="labels"
optionLabel="name"
optionValue="_id"
placeholder="Labels auswählen"
:maxSelectedLabels="5"
class="w-full"
display="chip"
/>
</div>
<button @click="saveTask" class="save-btn" :disabled="saving">
<i class="pi" :class="saving ? 'pi-spin pi-spinner' : 'pi-check'"></i>
{{ saving ? 'Speichert...' : 'Änderungen speichern' }}
</button>
<!-- Quick Action: nur Nächster Status (kein Nächster Task) -->
<div v-if="nextStatusLabel" class="quick-actions">
<button @click="advanceStatus" class="quick-btn next-status">
<i class="pi pi-arrow-right"></i>
{{ nextStatusLabel }}
</button>
</div>
<!-- Reminder Section -->
<div class="reminder-section">
<h3><i class="pi pi-bell"></i> Reminder</h3>
<div v-if="hasReminder && reminderDisplay" class="reminder-display">
<div class="reminder-info">
<span class="reminder-datetime">
<i class="pi pi-calendar"></i> {{ reminderDisplay.date }}
<i class="pi pi-clock"></i> {{ reminderDisplay.time }}
</span>
<span class="reminder-interval">{{ reminderDisplay.interval }}</span>
</div>
<div class="reminder-actions">
<button @click="openReminderForm" class="icon-btn" title="Bearbeiten">
<i class="pi pi-pencil"></i>
</button>
<button @click="deleteReminder" class="icon-btn delete" title="Löschen">
<i class="pi pi-trash"></i>
</button>
</div>
</div>
<div v-else-if="!showReminderForm" class="no-reminder">
<button @click="openReminderForm" class="add-reminder-btn">
<i class="pi pi-plus"></i> Reminder hinzufügen
</button>
</div>
<div v-if="showReminderForm" class="reminder-form">
<div class="form-row">
<div class="form-group">
<label>Datum</label>
<input v-model="reminderDate" type="date" />
</div>
<div class="form-group">
<label>Uhrzeit</label>
<input v-model="reminderTime" type="time" />
</div>
</div>
<div class="form-group">
<label>Wiederholung</label>
<select v-model="reminderInterval">
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div v-if="reminderInterval === 'minutes'" class="form-group">
<label>Alle X Minuten</label>
<input v-model.number="reminderMinutes" type="number" min="1" max="1440" />
</div>
<div class="reminder-form-actions">
<button @click="showReminderForm = false" class="cancel-btn">Abbrechen</button>
<button @click="saveReminder" class="save-reminder-btn" :disabled="savingReminder">
<i class="pi" :class="savingReminder ? 'pi-spin pi-spinner' : 'pi-bell'"></i>
{{ savingReminder ? 'Speichert...' : 'Reminder setzen' }}
</button>
</div>
</div>
</div>
<!-- Attachments -->
<div v-if="taskId" class="attachments-section">
<AttachmentList parentType="task" :parentId="taskId" :enablePaste="true" />
</div>
</div>
<!-- RIGHT: Comments + Changelog -->
<div v-if="taskId" class="comments-panel">
<CommentList parentType="task" :parentId="taskId" />
<TaskChangelog
:taskId="taskId"
:agents="agents"
:labels="labels"
:projects="projects"
/>
</div>
</div>
</Dialog>
</template>
<style scoped>
.dialog-loading {
text-align: center;
padding: 3rem;
}
.task-dialog-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
padding: 1.25rem;
max-height: 75vh;
overflow-y: auto;
}
@media (max-width: 768px) {
.task-dialog-layout {
grid-template-columns: 1fr;
}
}
/* Edit Panel */
.edit-panel {
display: flex;
flex-direction: column;
}
.form-group {
margin-bottom: 1rem;
position: relative;
}
.form-group label {
display: block;
margin-bottom: 0.4rem;
font-size: 0.85rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.input-with-stt {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.input-with-stt input,
.input-with-stt textarea {
flex: 1;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.65rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
font-family: inherit;
font-size: 0.9rem;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.form-group select option {
background: #1a1a2e;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.status-indicator {
position: absolute;
right: 12px;
top: 34px;
width: 10px;
height: 10px;
border-radius: 50%;
}
.save-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: none;
border-radius: 8px;
color: #fff;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s;
}
.save-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
}
.save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Quick Actions */
.quick-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.quick-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.6rem 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.quick-btn.next-status {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.quick-btn.next-status:hover {
background: rgba(34, 197, 94, 0.25);
color: #fff;
}
/* Reminder */
.reminder-section {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.reminder-section h3 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
gap: 0.5rem;
}
.reminder-display {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
padding: 0.75rem 1rem;
}
.reminder-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.reminder-datetime {
color: #fff;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.reminder-datetime i {
color: rgba(255, 255, 255, 0.5);
font-size: 0.75rem;
}
.reminder-interval {
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
}
.reminder-actions {
display: flex;
gap: 0.25rem;
}
.no-reminder {
text-align: center;
}
.add-reminder-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(99, 102, 241, 0.2);
border: 1px dashed rgba(99, 102, 241, 0.4);
border-radius: 8px;
color: #818cf8;
cursor: pointer;
font-size: 0.875rem;
}
.add-reminder-btn:hover {
background: rgba(99, 102, 241, 0.3);
}
.reminder-form {
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: 1rem;
}
.reminder-form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.cancel-btn {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
}
.cancel-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.save-reminder-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #f59e0b, #d97706);
border: none;
border-radius: 6px;
color: #fff;
font-weight: 500;
cursor: pointer;
}
.save-reminder-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Attachments */
.attachments-section {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Comments Panel */
.comments-panel {
display: flex;
flex-direction: column;
overflow-y: auto;
}
/* Icon buttons */
.icon-btn {
padding: 0.375rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
border-radius: 4px;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.icon-btn.delete:hover {
color: #f87171;
background: rgba(239, 68, 68, 0.2);
}
/* Labels - using PrimeVue MultiSelect */
/* Scrollbar */
.task-dialog-layout::-webkit-scrollbar {
width: 4px;
}
.task-dialog-layout::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
</style>

View File

@@ -0,0 +1,246 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useWebSocketStore } from '../stores/websocket'
import { api } from '../api'
const wsStore = useWebSocketStore()
interface ServerStats {
startedAt: string
totalConnections: number
totalDisconnections: number
totalBroadcasts: number
totalMessagesSent: number
totalMessagesReceived: number
currentConnections: number
users: Array<{
userId: string
username: string
connectedAt: string
messagesSent: number
messagesReceived: number
}>
}
const serverStats = ref<ServerStats | null>(null)
const loading = ref(false)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const uptime = computed(() => {
const connAt = wsStore.stats.connectedAt
if (!connAt) return '—'
const diff = Date.now() - new Date(connAt).getTime()
const secs = Math.floor(diff / 1000)
if (secs < 60) return `${secs}s`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m ${secs % 60}s`
const hrs = Math.floor(mins / 60)
return `${hrs}h ${mins % 60}m`
})
const serverUptime = computed(() => {
if (!serverStats.value?.startedAt) return '—'
const diff = Date.now() - new Date(serverStats.value.startedAt).getTime()
const secs = Math.floor(diff / 1000)
if (secs < 60) return `${secs}s`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ${mins % 60}m`
const days = Math.floor(hrs / 24)
return `${days}d ${hrs % 24}h`
})
async function fetchServerStats() {
loading.value = true
try {
const res = await api.get('/ws/status')
serverStats.value = res.data
} catch {
serverStats.value = null
} finally {
loading.value = false
}
}
onMounted(() => {
fetchServerStats()
refreshTimer = setInterval(fetchServerStats, 10000)
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<template>
<div class="ws-stats">
<div class="stats-header">
<span class="status-dot" :class="{ online: wsStore.isConnected }"></span>
<span class="status-text">{{ wsStore.isConnected ? 'Verbunden' : 'Getrennt' }}</span>
</div>
<div class="stats-section">
<h4>Deine Session</h4>
<div class="stat-grid">
<div class="stat-item">
<span class="stat-label">Verbunden seit</span>
<span class="stat-value">{{ uptime }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Events empfangen</span>
<span class="stat-value">{{ wsStore.stats.eventsReceived }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Events gesendet</span>
<span class="stat-value">{{ wsStore.stats.eventsSent }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Reconnects</span>
<span class="stat-value">{{ Math.max(0, wsStore.stats.reconnects) }}</span>
</div>
<div class="stat-item" v-if="wsStore.stats.lastEventType">
<span class="stat-label">Letztes Event</span>
<span class="stat-value mono">{{ wsStore.stats.lastEventType }}</span>
</div>
</div>
</div>
<div class="stats-section" v-if="serverStats">
<h4>Server</h4>
<div class="stat-grid">
<div class="stat-item">
<span class="stat-label">Uptime</span>
<span class="stat-value">{{ serverUptime }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Verbindungen aktiv</span>
<span class="stat-value">{{ serverStats.currentConnections }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Verbindungen gesamt</span>
<span class="stat-value">{{ serverStats.totalConnections }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Broadcasts</span>
<span class="stat-value">{{ serverStats.totalBroadcasts }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Nachrichten gesendet</span>
<span class="stat-value">{{ serverStats.totalMessagesSent }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Nachrichten empfangen</span>
<span class="stat-value">{{ serverStats.totalMessagesReceived }}</span>
</div>
</div>
<div class="connected-users" v-if="serverStats.users.length">
<h4>Verbundene User</h4>
<div class="user-row" v-for="user in serverStats.users" :key="user.userId">
<span class="user-dot"></span>
<span class="user-name">{{ user.username }}</span>
<span class="user-msgs">{{ user.messagesSent }} {{ user.messagesReceived }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.ws-stats {
padding: 1rem;
min-width: 260px;
max-width: 320px;
}
.stats-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.status-dot {
color: rgba(239, 68, 68, 0.8);
font-size: 0.75rem;
}
.status-dot.online {
color: rgba(34, 197, 94, 0.9);
}
.status-text {
font-size: 0.875rem;
}
.stats-section {
margin-bottom: 0.75rem;
}
.stats-section h4 {
color: rgba(255, 255, 255, 0.5);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.5rem 0;
}
.stat-grid {
display: grid;
gap: 0.4rem;
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-label {
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
}
.stat-value {
color: rgba(255, 255, 255, 0.9);
font-size: 0.8rem;
font-weight: 500;
}
.stat-value.mono {
font-family: monospace;
font-size: 0.75rem;
}
.connected-users {
margin-top: 0.75rem;
}
.user-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0;
}
.user-dot {
color: rgba(34, 197, 94, 0.8);
font-size: 0.5rem;
}
.user-name {
color: rgba(255, 255, 255, 0.8);
font-size: 0.8rem;
flex: 1;
}
.user-msgs {
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
font-family: monospace;
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import Button from 'primevue/button'
interface Props {
label?: string
icon?: string
severity?: 'primary' | 'secondary' | 'success' | 'info' | 'warn' | 'danger' | 'help' | 'contrast'
variant?: 'outlined' | 'text'
size?: 'small' | 'large'
loading?: boolean
disabled?: boolean
badge?: string
}
withDefaults(defineProps<Props>(), {
severity: 'primary',
loading: false,
disabled: false
})
defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<template>
<Button
:label="label"
:icon="icon"
:severity="severity"
:variant="variant"
:size="size"
:loading="loading"
:disabled="disabled"
:badge="badge"
@click="$emit('click', $event)"
/>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
interface Props {
title?: string
subtitle?: string
}
defineProps<Props>()
</script>
<template>
<div class="base-card">
<div v-if="title || $slots.header" class="base-card-header">
<slot name="header">
<h3 v-if="title" class="base-card-title">{{ title }}</h3>
<p v-if="subtitle" class="base-card-subtitle">{{ subtitle }}</p>
</slot>
</div>
<div class="base-card-body">
<slot />
</div>
<div v-if="$slots.footer" class="base-card-footer">
<slot name="footer" />
</div>
</div>
</template>
<style scoped>
.base-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
overflow: hidden;
}
.base-card-header {
padding: 1rem 1.25rem 0;
}
.base-card-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.base-card-subtitle {
margin: 0.25rem 0 0;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
}
.base-card-body {
padding: 1rem 1.25rem;
}
.base-card-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
interface Props {
modelValue?: string | number
label?: string
placeholder?: string
error?: string
disabled?: boolean
type?: 'text' | 'email' | 'password' | 'number' | 'search'
}
withDefaults(defineProps<Props>(), {
modelValue: '',
type: 'text',
disabled: false
})
defineEmits<{
'update:modelValue': [value: string | number]
}>()
</script>
<template>
<div class="base-field">
<label v-if="label" class="base-label">{{ label }}</label>
<InputText
:model-value="String(modelValue)"
:placeholder="placeholder"
:disabled="disabled"
:type="type"
:class="{ 'p-invalid': !!error }"
class="base-input"
@update:model-value="$emit('update:modelValue', type === 'number' ? Number($event) : $event)"
/>
<small v-if="error" class="base-error">{{ error }}</small>
</div>
</template>
<style scoped>
.base-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.base-label {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.base-input {
width: 100%;
}
.base-error {
color: #ef4444;
font-size: 0.75rem;
}
</style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog'
interface Props {
visible: boolean
header?: string
modal?: boolean
closable?: boolean
draggable?: boolean
maximizable?: boolean
style?: Record<string, string>
}
withDefaults(defineProps<Props>(), {
modal: true,
closable: true,
draggable: false,
maximizable: false
})
defineEmits<{
'update:visible': [value: boolean]
}>()
</script>
<template>
<Dialog
:visible="visible"
:header="header"
:modal="modal"
:closable="closable"
:draggable="draggable"
:maximizable="maximizable"
:style="style"
@update:visible="$emit('update:visible', $event)"
>
<template v-if="$slots.header" #header>
<slot name="header" />
</template>
<slot />
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import Select from 'primevue/select'
interface SelectOption {
label: string
value: string | number
}
interface Props {
modelValue?: string | number | null
options: SelectOption[]
label?: string
placeholder?: string
error?: string
disabled?: boolean
optionLabel?: string
optionValue?: string
}
withDefaults(defineProps<Props>(), {
modelValue: null,
disabled: false,
optionLabel: 'label',
optionValue: 'value'
})
defineEmits<{
'update:modelValue': [value: string | number | null]
}>()
</script>
<template>
<div class="base-field">
<label v-if="label" class="base-label">{{ label }}</label>
<Select
:model-value="modelValue"
:options="options"
:option-label="optionLabel"
:option-value="optionValue"
:placeholder="placeholder"
:disabled="disabled"
:class="{ 'p-invalid': !!error }"
class="base-select"
@update:model-value="$emit('update:modelValue', $event)"
/>
<small v-if="error" class="base-error">{{ error }}</small>
</div>
</template>
<style scoped>
.base-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.base-label {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.base-select {
width: 100%;
}
.base-error {
color: #ef4444;
font-size: 0.75rem;
}
</style>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import Textarea from 'primevue/textarea'
interface Props {
modelValue?: string
label?: string
placeholder?: string
error?: string
disabled?: boolean
rows?: number
autoResize?: boolean
}
withDefaults(defineProps<Props>(), {
modelValue: '',
disabled: false,
rows: 3,
autoResize: false
})
defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<div class="base-field">
<label v-if="label" class="base-label">{{ label }}</label>
<Textarea
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:rows="rows"
:auto-resize="autoResize"
:class="{ 'p-invalid': !!error }"
class="base-textarea"
@update:model-value="$emit('update:modelValue', $event)"
/>
<small v-if="error" class="base-error">{{ error }}</small>
</div>
</template>
<style scoped>
.base-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.base-label {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.base-textarea {
width: 100%;
}
.base-error {
color: #ef4444;
font-size: 0.75rem;
}
</style>

View File

@@ -0,0 +1,6 @@
export { default as BaseInput } from './BaseInput.vue'
export { default as BaseSelect } from './BaseSelect.vue'
export { default as BaseTextarea } from './BaseTextarea.vue'
export { default as BaseButton } from './BaseButton.vue'
export { default as BaseModal } from './BaseModal.vue'
export { default as BaseCard } from './BaseCard.vue'

296
src/layouts/AppLayout.vue Normal file
View File

@@ -0,0 +1,296 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useWebSocketStore } from '../stores/websocket'
import Popover from 'primevue/popover'
import WsStatsPanel from '../components/WsStatsPanel.vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const wsStore = useWebSocketStore()
const wsPopover = ref()
const sidebarOpen = ref(false)
// Close sidebar on route change (mobile)
watch(() => route.path, () => {
sidebarOpen.value = false
})
const feVersion = import.meta.env.VITE_APP_VERSION || 'dev'
const beVersion = ref('...')
onMounted(async () => {
try {
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
const res = await fetch(`${apiBase}/version`)
const data = await res.json()
beVersion.value = data.version || 'unknown'
} catch {
beVersion.value = '?'
}
wsStore.connect()
})
onUnmounted(() => {
wsStore.disconnect()
})
function handleLogout() {
wsStore.disconnect()
authStore.logout()
router.push('/login')
}
function toggleWsStats(event: Event) {
wsPopover.value?.toggle(event)
}
</script>
<template>
<div class="app-layout">
<!-- Mobile Hamburger -->
<button class="hamburger" @click="sidebarOpen = !sidebarOpen" :class="{ open: sidebarOpen }">
<i :class="sidebarOpen ? 'pi pi-times' : 'pi pi-bars'"></i>
</button>
<!-- Overlay -->
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false"></div>
<aside class="sidebar" :class="{ open: sidebarOpen }">
<div class="sidebar-header">
<h2>🤖 AMS</h2>
</div>
<nav class="sidebar-nav-container">
<ul class="sidebar-nav">
<li>
<router-link to="/">
<i class="pi pi-home"></i>
Dashboard
</router-link>
</li>
<li>
<router-link to="/agents">
<i class="pi pi-users"></i>
Agents
</router-link>
</li>
<li>
<router-link to="/projects">
<i class="pi pi-folder"></i>
Projekte
</router-link>
</li>
<li>
<router-link to="/tasks">
<i class="pi pi-list-check"></i>
Tasks
</router-link>
</li>
<li>
<router-link to="/logs">
<i class="pi pi-file"></i>
Logs
</router-link>
</li>
<li>
<router-link to="/labels">
<i class="pi pi-tags"></i>
Labels
</router-link>
</li>
<li>
<router-link to="/gitlab">
<i class="pi pi-github"></i>
GitLab
</router-link>
</li>
<li>
<router-link to="/agent-task">
<i class="pi pi-send"></i>
Aufgabe stellen
</router-link>
</li>
<li>
<router-link to="/agent-tasks-overview">
<i class="pi pi-list-check"></i>
Agent-Aufträge
</router-link>
</li>
<li>
<router-link to="/cronjobs">
<i class="pi pi-clock"></i>
CronJobs
</router-link>
</li>
<li>
<router-link to="/workspace">
<i class="pi pi-folder-open"></i>
Agent-Dateien
</router-link>
</li>
<li>
<router-link to="/secrets">
<i class="pi pi-lock"></i>
Secrets
</router-link>
</li>
<li>
<router-link to="/token-analytics">
<i class="pi pi-chart-line"></i>
Token Analytics
</router-link>
</li>
<li v-if="authStore.user?.role === 'admin'">
<router-link to="/containers">
<i class="pi pi-box"></i>
Container
</router-link>
</li>
<li>
<router-link to="/settings">
<i class="pi pi-cog"></i>
Einstellungen
</router-link>
</li>
</ul>
</nav>
<div class="sidebar-footer">
<div style="color: rgba(255,255,255,0.5); font-size: 0.875rem; margin-bottom: 0.5rem;">
{{ authStore.user?.username }}
</div>
<button
@click="handleLogout"
style="background: none; border: 1px solid rgba(255,255,255,0.2); color: rgba(255,255,255,0.7); padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; width: 100%;"
>
<i class="pi pi-sign-out" style="margin-right: 0.5rem;"></i>
Abmelden
</button>
<div class="sidebar-version">
<span class="ws-indicator" :class="{ online: wsStore.isConnected }" :title="wsStore.isConnected ? 'WebSocket verbunden' : 'WebSocket getrennt'" @click="toggleWsStats" style="cursor: pointer;"></span>
FE {{ feVersion }} | BE {{ beVersion }}
</div>
<Popover ref="wsPopover">
<WsStatsPanel />
</Popover>
</div>
</aside>
<main class="main-content">
<router-view />
</main>
</div>
</template>
<style scoped>
.hamburger {
display: none;
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 1100;
width: 40px;
height: 40px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(26, 26, 46, 0.95);
color: rgba(255, 255, 255, 0.8);
font-size: 1.1rem;
cursor: pointer;
align-items: center;
justify-content: center;
backdrop-filter: blur(8px);
transition: all 0.2s;
}
.hamburger:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.sidebar-overlay {
display: none;
}
.sidebar {
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
}
@media (max-width: 768px) {
.hamburger {
display: flex;
}
.sidebar-overlay {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
backdrop-filter: blur(2px);
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 1000;
transform: translateX(-100%);
width: 220px;
background: #0f0f1e;
}
.sidebar.open {
transform: translateX(0);
}
.main-content {
margin-left: 0 !important;
padding-top: 3.5rem !important;
}
}
.sidebar-version {
text-align: center;
margin-top: 0.75rem;
color: rgba(255, 255, 255, 0.25);
font-size: 0.65rem;
letter-spacing: 0.025em;
}
.ws-indicator {
color: rgba(239, 68, 68, 0.6);
font-size: 0.5rem;
vertical-align: middle;
margin-right: 0.2rem;
}
.ws-indicator.online {
color: rgba(34, 197, 94, 0.8);
}
/* Popover Dark Theme */
:deep(.p-popover) {
background: #1a1a2e !important;
border: 1px solid rgba(255, 255, 255, 0.15) !important;
color: #fff !important;
}
:deep(.p-popover-content) {
background: #1a1a2e !important;
color: #fff !important;
}
:deep(.p-popover)::before,
:deep(.p-popover)::after {
border-color: transparent !important;
border-bottom-color: #1a1a2e !important;
}
</style>

29
src/main.ts Normal file
View File

@@ -0,0 +1,29 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import App from './App.vue'
import router from './router'
import 'primeicons/primeicons.css'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: '.dark-mode'
}
}
})
app.use(ToastService)
app.use(ConfirmationService)
app.mount('#app')

121
src/router/index.ts Normal file
View File

@@ -0,0 +1,121 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
meta: { requiresGuest: true }
},
{
path: '/register',
name: 'register',
component: () => import('../views/RegisterView.vue'),
meta: { requiresGuest: true }
},
{
path: '/',
component: () => import('../layouts/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'dashboard',
component: () => import('../views/DashboardView.vue')
},
{
path: 'agents',
name: 'agents',
component: () => import('../views/AgentsView.vue')
},
{
path: 'tasks',
name: 'tasks',
component: () => import('../views/TasksView.vue')
},
{
path: 'tasks/:id',
name: 'task-detail',
component: () => import('../views/TaskDetailView.vue')
},
{
path: 'projects',
name: 'projects',
component: () => import('../views/ProjectsView.vue')
},
{
path: 'logs',
name: 'logs',
component: () => import('../views/LogsView.vue')
},
{
path: 'labels',
name: 'labels',
component: () => import('../views/LabelsView.vue')
},
{
path: 'settings',
name: 'settings',
component: () => import('../views/SettingsView.vue')
},
{
path: 'gitlab',
name: 'gitlab',
component: () => import('../views/GitLabView.vue')
},
{
path: 'containers',
name: 'containers',
component: () => import('../views/ContainersView.vue')
},
{
path: 'agent-task',
name: 'agent-task',
component: () => import('../views/AgentTaskView.vue')
},
{
path: 'workspace',
name: 'workspace',
component: () => import('../views/WorkspaceView.vue')
},
{
path: 'agent-tasks-overview',
name: 'agent-tasks-overview',
component: () => import('../views/AgentTasksOverview.vue')
},
{
path: 'cronjobs',
name: 'cronjobs',
component: () => import('../views/CronJobsView.vue')
},
{
path: 'secrets',
name: 'secrets',
component: () => import('../views/SecretsView.vue')
},
{
path: 'token-analytics',
name: 'token-analytics',
component: () => import('../views/TokenAnalyticsView.vue')
}
]
}
]
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login')
} else if (to.meta.requiresGuest && authStore.isAuthenticated) {
next('/')
} else {
next()
}
})
export default router

69
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,69 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../api'
interface User {
id: string
email: string
username: string
role: string
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!token.value)
function loadFromStorage() {
const storedToken = localStorage.getItem('ams_token')
const storedUser = localStorage.getItem('ams_user')
if (storedToken && storedUser) {
token.value = storedToken
user.value = JSON.parse(storedUser)
}
}
function saveToStorage() {
if (token.value && user.value) {
localStorage.setItem('ams_token', token.value)
localStorage.setItem('ams_user', JSON.stringify(user.value))
}
}
function clearStorage() {
localStorage.removeItem('ams_token')
localStorage.removeItem('ams_user')
}
async function login(email: string, password: string) {
const response = await api.post('/auth/login', { email, password })
token.value = response.token
user.value = response.user
saveToStorage()
}
async function register(email: string, username: string, password: string) {
const response = await api.post('/auth/register', { email, username, password })
token.value = response.token
user.value = response.user
saveToStorage()
}
function logout() {
token.value = null
user.value = null
clearStorage()
}
return {
token,
user,
isAuthenticated,
loadFromStorage,
login,
register,
logout
}
})

161
src/stores/websocket.ts Normal file
View File

@@ -0,0 +1,161 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface WsMessage {
event: string
data: unknown
timestamp: string
}
type EventHandler = (data: unknown) => void
export interface WsSessionStats {
connectedAt: string | null
eventsReceived: number
eventsSent: number
reconnects: number
lastEventType: string | null
lastEventTime: string | null
}
export const useWebSocketStore = defineStore('websocket', () => {
const connected = ref(false)
const connectedUsers = ref(0)
const lastEvent = ref<WsMessage | null>(null)
// Session statistics
const stats = ref<WsSessionStats>({
connectedAt: null,
eventsReceived: 0,
eventsSent: 0,
reconnects: -1, // First connect is not a reconnect
lastEventType: null,
lastEventTime: null
})
let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let pingTimer: ReturnType<typeof setInterval> | null = null
const handlers: Map<string, Set<EventHandler>> = new Map()
const isConnected = computed(() => connected.value)
function getWsUrl(): string {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
return apiUrl
.replace(/^https:/, 'wss:')
.replace(/^http:/, 'ws:')
.replace(/\/api$/, '/ws')
}
function connect(): void {
const token = localStorage.getItem('ams_token')
if (!token || ws?.readyState === WebSocket.OPEN) return
const url = `${getWsUrl()}?token=${token}`
stats.value.reconnects++
try {
ws = new WebSocket(url)
} catch {
scheduleReconnect()
return
}
ws.onopen = () => {
connected.value = true
stats.value.connectedAt = new Date().toISOString()
pingTimer = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
stats.value.eventsSent++
}
}, 30000)
}
ws.onmessage = (e) => {
try {
const msg: WsMessage = JSON.parse(e.data)
lastEvent.value = msg
stats.value.eventsReceived++
stats.value.lastEventType = msg.event
stats.value.lastEventTime = msg.timestamp || new Date().toISOString()
if (msg.event === 'connected') {
const data = msg.data as { connectedUsers: number }
connectedUsers.value = data.connectedUsers
}
handlers.get(msg.event)?.forEach((handler) => {
try { handler(msg.data) } catch { /* ignore */ }
})
handlers.get('*')?.forEach((handler) => {
try { handler(msg) } catch { /* ignore */ }
})
} catch {
// Ignore malformed
}
}
ws.onclose = () => {
cleanup()
scheduleReconnect()
}
ws.onerror = () => {
cleanup()
scheduleReconnect()
}
}
function disconnect(): void {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (ws) {
ws.onclose = null
ws.close()
ws = null
}
cleanup()
}
function cleanup(): void {
connected.value = false
if (pingTimer) {
clearInterval(pingTimer)
pingTimer = null
}
}
function scheduleReconnect(): void {
if (reconnectTimer) return
reconnectTimer = setTimeout(() => {
reconnectTimer = null
const token = localStorage.getItem('ams_token')
if (token) connect()
}, 3000)
}
function on(event: string, handler: EventHandler): void {
if (!handlers.has(event)) handlers.set(event, new Set())
handlers.get(event)!.add(handler)
}
function off(event: string, handler: EventHandler): void {
handlers.get(event)?.delete(handler)
}
return {
connected,
connectedUsers,
lastEvent,
stats,
isConnected,
connect,
disconnect,
on,
off
}
})

475
src/style.css Normal file
View File

@@ -0,0 +1,475 @@
* {
box-sizing: border-box;
}
/* Modern Scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
transition: background 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.35);
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
}
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.login-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 400px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: #fff;
}
.login-header p {
margin: 0.5rem 0 0;
color: rgba(255, 255, 255, 0.6);
font-size: 0.9rem;
}
.form-field {
margin-bottom: 1.25rem;
}
.form-field label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
}
.form-field input,
.form-field select,
.form-field textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.form-field input::placeholder,
.form-field textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
}
/* Fix select dropdown colors */
.form-field select option {
background: #1a1a2e;
color: #fff;
}
select {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
select option {
background: #1a1a2e;
color: #fff;
padding: 0.5rem;
}
.submit-btn {
width: 100%;
padding: 0.875rem;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.switch-mode {
text-align: center;
margin-top: 1.5rem;
color: rgba(255, 255, 255, 0.6);
font-size: 0.9rem;
}
.switch-mode a {
color: #8b5cf6;
text-decoration: none;
font-weight: 500;
}
.switch-mode a:hover {
text-decoration: underline;
}
.version-info {
text-align: center;
margin-top: 1.5rem;
color: rgba(255, 255, 255, 0.3);
font-size: 0.75rem;
letter-spacing: 0.025em;
}
.error-message {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
padding: 0.5rem 0.75rem;
margin-bottom: 1rem;
color: #fca5a5;
font-size: 0.875rem;
}
/* Dashboard Layout */
html, body, #app {
height: 100%;
overflow: hidden;
}
.app-layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 130px;
min-width: 130px;
background: rgba(0, 0, 0, 0.3);
border-right: 1px solid rgba(255, 255, 255, 0.1);
padding: 0.75rem;
height: 100vh;
height: 100dvh;
position: fixed;
left: 0;
top: 0;
overflow: hidden;
z-index: 100;
display: flex;
flex-direction: column;
}
.sidebar-header {
margin-bottom: 1rem;
flex-shrink: 0;
}
.sidebar-nav-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.sidebar-footer {
flex-shrink: 0;
padding-top: 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
margin-top: 0.25rem;
}
.sidebar-header h2 {
margin: 0;
font-size: 1rem;
color: #fff;
}
.sidebar-nav {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-nav li {
margin-bottom: 0.5rem;
}
.sidebar-nav a {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.sidebar-nav a:hover,
.sidebar-nav a.router-link-active {
background: rgba(99, 102, 241, 0.2);
color: #fff;
}
.main-content {
flex: 1;
margin-left: 130px;
padding: 0.75rem;
height: 100vh;
overflow-y: auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
margin: 0;
font-size: 1.75rem;
}
/* PrimeVue Popover Dark Theme */
/* Dialog Dark Theme (global, PrimeVue Dialoge werden per Teleport ins body gerendert) */
.p-dialog { background: #1a1a2e !important; border: 1px solid rgba(255, 255, 255, 0.15) !important; color: #fff !important; }
.p-dialog-header { background: #1a1a2e !important; color: #fff !important; border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important; padding: 1.25rem 1.5rem !important; }
.p-dialog-content { background: #1a1a2e !important; color: #fff !important; padding: 1.25rem 1.5rem !important; }
.p-dialog-footer { background: #1a1a2e !important; border-top: 1px solid rgba(255, 255, 255, 0.1) !important; padding: 1rem 1.5rem !important; }
.p-dialog-header-close { color: rgba(255, 255, 255, 0.6) !important; }
.p-dialog-header-close:hover { color: #fff !important; background: rgba(255, 255, 255, 0.1) !important; }
/* PrimeVue Form Inputs Dark Theme (global für Dialog-Teleport) */
.p-dialog .p-inputtext,
.p-dialog .p-textarea,
.p-dialog .p-password input,
.p-dialog input.p-inputtext {
background: rgba(255, 255, 255, 0.06) !important;
border: 1px solid rgba(255, 255, 255, 0.15) !important;
color: #fff !important;
border-radius: 8px !important;
}
.p-dialog .p-inputtext:focus,
.p-dialog .p-textarea:focus,
.p-dialog .p-password input:focus,
.p-dialog input.p-inputtext:focus {
border-color: #6366f1 !important;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2) !important;
}
.p-dialog .p-inputtext::placeholder,
.p-dialog .p-textarea::placeholder,
.p-dialog .p-password input::placeholder {
color: rgba(255, 255, 255, 0.35) !important;
}
.p-dialog .p-select,
.p-dialog .p-multiselect {
background: rgba(255, 255, 255, 0.06) !important;
border: 1px solid rgba(255, 255, 255, 0.15) !important;
color: #fff !important;
border-radius: 8px !important;
}
.p-dialog .p-select:hover,
.p-dialog .p-multiselect:hover {
border-color: rgba(255, 255, 255, 0.3) !important;
}
.p-dialog .p-select.p-focus,
.p-dialog .p-multiselect.p-focus {
border-color: #6366f1 !important;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2) !important;
}
.p-dialog .p-select-label,
.p-dialog .p-multiselect-label {
color: #fff !important;
}
.p-dialog .p-checkbox .p-checkbox-box {
background: rgba(255, 255, 255, 0.06) !important;
border-color: rgba(255, 255, 255, 0.25) !important;
}
.p-dialog .p-checkbox .p-checkbox-box.p-highlight {
background: #6366f1 !important;
border-color: #6366f1 !important;
}
.p-dialog .p-slider {
background: rgba(255, 255, 255, 0.15) !important;
}
.p-dialog .p-slider .p-slider-range {
background: #6366f1 !important;
}
.p-dialog .p-slider .p-slider-handle {
background: #6366f1 !important;
border-color: #6366f1 !important;
}
.p-dialog .p-password-toggle-btn {
color: rgba(255, 255, 255, 0.5) !important;
}
.p-dialog .p-password-toggle-btn:hover {
color: #fff !important;
}
/* PrimeVue Select/Multiselect Dropdown Panel Dark */
.p-select-overlay,
.p-multiselect-overlay {
background: #1e2235 !important;
border: 1px solid rgba(255, 255, 255, 0.15) !important;
color: #fff !important;
}
.p-select-option,
.p-multiselect-option {
color: rgba(255, 255, 255, 0.8) !important;
}
.p-select-option:hover,
.p-multiselect-option:hover,
.p-select-option.p-focus,
.p-multiselect-option.p-focus {
background: rgba(99, 102, 241, 0.2) !important;
color: #fff !important;
}
.p-select-option.p-highlight,
.p-multiselect-option.p-highlight {
background: rgba(99, 102, 241, 0.3) !important;
color: #fff !important;
}
/* ConfirmDialog Dark */
.p-confirmdialog .p-confirmdialog-message {
color: rgba(255, 255, 255, 0.8) !important;
}
.p-confirmdialog .p-confirmdialog-icon {
color: rgba(255, 255, 255, 0.5) !important;
}
.p-dialog .p-dialog-footer .p-button,
.p-confirmdialog .p-dialog-footer .p-button,
.p-confirmdialog-footer .p-button,
.p-dialog-footer button.p-button {
display: inline-flex !important;
align-items: center !important;
gap: 0.4rem !important;
padding: 0.5rem 1rem !important;
background: rgba(255, 255, 255, 0.06) !important;
border: 1px solid rgba(255, 255, 255, 0.15) !important;
border-radius: 8px !important;
color: rgba(255, 255, 255, 0.8) !important;
font-size: 0.85rem !important;
font-weight: 500 !important;
cursor: pointer !important;
transition: all 0.2s !important;
}
.p-dialog .p-dialog-footer .p-button:hover,
.p-confirmdialog .p-dialog-footer .p-button:hover,
.p-dialog-footer button.p-button:hover {
background: rgba(255, 255, 255, 0.12) !important;
color: #fff !important;
}
.p-dialog .p-dialog-footer .p-button-danger,
.p-confirmdialog .p-dialog-footer .p-button-danger,
.p-dialog-footer button.p-button-danger {
background: rgba(239, 68, 68, 0.15) !important;
border-color: rgba(239, 68, 68, 0.3) !important;
color: #fca5a5 !important;
}
.p-dialog .p-dialog-footer .p-button-danger:hover,
.p-confirmdialog .p-dialog-footer .p-button-danger:hover,
.p-dialog-footer button.p-button-danger:hover {
background: rgba(239, 68, 68, 0.3) !important;
color: #fff !important;
}
.p-popover {
background: #1a1a2e !important;
border: 1px solid rgba(255, 255, 255, 0.15) !important;
color: #fff !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5) !important;
}
.p-popover-content {
background: #1a1a2e !important;
color: #fff !important;
}
/* Compact sidebar text */
.sidebar-nav a {
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ========= Mobile Responsive ========= */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
width: 220px;
min-width: 220px;
background: #0f0f1e;
z-index: 1000;
transition: transform 0.3s ease;
height: 100dvh;
height: -webkit-fill-available;
overflow-y: auto;
}
.sidebar.open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
padding-top: 3.5rem;
}
}

103
src/utils/appUpdate.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Auto-Update Service für die AMS Android App (Capacitor)
*
* Prüft beim App-Start ob eine neue Version verfügbar ist
* und zeigt einen Update-Dialog an.
*/
const API_BASE = 'https://api.ams.agentenbude.de'
const APP_VERSION_CODE = 1 // Muss bei jedem Release erhöht werden
const APP_VERSION = '1.0.0'
interface UpdateCheckResponse {
updateAvailable: boolean
currentVersion: string
latestVersion: string
latestVersionCode: number
downloadUrl: string
releaseNotes: string
size: number
}
function isCapacitorApp(): boolean {
return !!(window as Record<string, unknown>).Capacitor
}
export async function checkForUpdate(): Promise<UpdateCheckResponse | null> {
if (!isCapacitorApp()) return null
try {
const response = await fetch(
`${API_BASE}/api/app/update/check?currentVersion=${APP_VERSION}&currentCode=${APP_VERSION_CODE}`
)
if (!response.ok) return null
return await response.json()
} catch {
console.warn('Update-Check fehlgeschlagen')
return null
}
}
export function showUpdateDialog(update: UpdateCheckResponse): void {
const sizeStr = update.size
? `(${(update.size / 1024 / 1024).toFixed(1)} MB)`
: ''
const dialog = document.createElement('div')
dialog.id = 'app-update-dialog'
dialog.innerHTML = `
<div style="
position: fixed; inset: 0; z-index: 99999;
background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
">
<div style="
background: #1a1a2e; color: #fff;
border-radius: 16px; padding: 24px;
max-width: 340px; width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
">
<h3 style="margin: 0 0 8px; font-size: 18px;">🔄 Update verfügbar</h3>
<p style="margin: 0 0 12px; color: rgba(255,255,255,0.7); font-size: 14px;">
Version <strong>${update.latestVersion}</strong> ist verfügbar ${sizeStr}
</p>
${update.releaseNotes ? `
<p style="margin: 0 0 16px; color: rgba(255,255,255,0.5); font-size: 13px;">
${update.releaseNotes}
</p>
` : ''}
<div style="display: flex; gap: 12px;">
<button id="update-later" style="
flex: 1; padding: 12px; border: 1px solid rgba(255,255,255,0.2);
background: transparent; color: #fff; border-radius: 8px;
font-size: 14px; cursor: pointer;
">Später</button>
<button id="update-now" style="
flex: 1; padding: 12px; border: none;
background: #4ade80; color: #000; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
">Jetzt updaten</button>
</div>
</div>
</div>
`
document.body.appendChild(dialog)
document.getElementById('update-later')?.addEventListener('click', () => {
dialog.remove()
})
document.getElementById('update-now')?.addEventListener('click', () => {
window.open(update.downloadUrl, '_system')
dialog.remove()
})
}
export async function initUpdateCheck(): Promise<void> {
const update = await checkForUpdate()
if (update?.updateAvailable) {
showUpdateDialog(update)
}
}

970
src/views/AgentTaskView.vue Normal file
View File

@@ -0,0 +1,970 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
import { useWebSocketStore } from '../stores/websocket'
import Dialog from 'primevue/dialog'
import Dropdown from 'primevue/dropdown'
import TaskEditDialog from '../components/TaskEditDialog.vue'
const toast = useToast()
const wsStore = useWebSocketStore()
// --- Daten ---
interface Project {
_id: string
name: string
}
interface Task {
_id: string
number?: number
title: string
status: string
priority: string
}
interface QuickText {
_id: string
title: string
text: string
sortOrder: number
}
interface Agent {
_id: string
name: string
emoji: string
}
const messageText = ref('')
const projects = ref<Project[]>([])
const tasks = ref<Task[]>([])
const agents = ref<Agent[]>([])
const quickTexts = ref<QuickText[]>([])
const selectedProject = ref<string | null>(localStorage.getItem('ams_agent_task_project') || null)
const selectedTasks = ref<string[]>([])
const selectedAgent = ref<string | null>(localStorage.getItem('ams_agent_task_agent') || null)
const sending = ref(false)
const recording = ref(false)
const transcribing = ref(false)
// Task-Detail Dialog
const taskDialogVisible = ref(false)
const taskDialogId = ref<string | null>(null)
function openTaskDialog(taskId: string) {
taskDialogId.value = taskId
taskDialogVisible.value = true
}
function onTaskDialogSaved() {
// Reload tasks to reflect changes
if (selectedProject.value) {
loadProjectTasks(selectedProject.value)
} else {
loadTasks()
}
}
// Quick-Text Dialog
const quickTextDialog = ref(false)
const editingQuickText = ref<QuickText | null>(null)
const qtTitle = ref('')
const qtText = ref('')
// MediaRecorder
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
// --- LocalStorage persist ---
watch(selectedProject, (val) => {
if (val) localStorage.setItem('ams_agent_task_project', val)
else localStorage.removeItem('ams_agent_task_project')
})
watch(selectedAgent, (val) => {
if (val) localStorage.setItem('ams_agent_task_agent', val)
else localStorage.removeItem('ams_agent_task_agent')
})
// --- Computed ---
const filteredTasks = computed(() => {
if (!selectedProject.value) return tasks.value
return tasks.value
})
const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p._id }))
)
const agentOptions = computed(() =>
agents.value.map(a => ({ label: `${a.emoji} ${a.name}`, value: a._id }))
)
// --- API Calls ---
async function loadProjects() {
try {
const res = await api.get<{ projects: Project[] }>('/tasks/projects/list')
projects.value = res.projects || []
} catch {
// silent
}
}
const statusLabels: Record<string, string> = {
in_progress: 'In Progress',
pending: 'Pending',
review: 'Review',
done: 'Done',
rejected: 'Rejected',
backlog: 'Backlog',
}
function formatStatus(status: string): string {
return statusLabels[status] || status
}
function sortTasks() {
tasks.value.sort((a, b) => {
const p: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 }
return (p[String(a.priority || 'medium')] ?? 4) - (p[String(b.priority || 'medium')] ?? 4)
})
}
async function loadTasks() {
try {
const res = await api.get<{ tasks: Task[] }>('/tasks')
const openStatuses = ['todo', 'pending']
tasks.value = (res.tasks || []).filter(t => openStatuses.includes(String(t.status)))
sortTasks()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Tasks konnten nicht geladen werden', life: 3000 })
}
}
async function loadAgents() {
try {
const res = await api.get<{ agents: Agent[] }>('/agents')
agents.value = res.agents || []
} catch {
// silent
}
}
async function loadQuickTexts() {
try {
const res = await api.get<{ quickTexts: QuickText[] }>('/quicktexts')
quickTexts.value = res.quickTexts || []
} catch {
// silent
}
}
async function loadProjectTasks(projectId: string) {
try {
const res = await api.get<{ tasks: Task[] }>(`/tasks?project=${projectId}`)
const openStatuses = ['todo', 'pending']
tasks.value = (res.tasks || []).filter(t => openStatuses.includes(String(t.status)))
sortTasks()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Tasks konnten nicht geladen werden', life: 3000 })
}
}
// --- Quick-Texts CRUD ---
function openQuickTextDialog(qt?: QuickText) {
if (qt) {
editingQuickText.value = qt
qtTitle.value = qt.title
qtText.value = qt.text
} else {
editingQuickText.value = null
qtTitle.value = ''
qtText.value = ''
}
quickTextDialog.value = true
}
async function saveQuickText() {
if (!qtTitle.value || !qtText.value) return
try {
if (editingQuickText.value) {
await api.put(`/quicktexts/${editingQuickText.value._id}`, { title: qtTitle.value, text: qtText.value })
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Quick-Text aktualisiert', life: 2000 })
} else {
await api.post('/quicktexts', { title: qtTitle.value, text: qtText.value })
toast.add({ severity: 'success', summary: 'Erstellt', detail: 'Quick-Text hinzugefügt', life: 2000 })
}
quickTextDialog.value = false
await loadQuickTexts()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Speichern fehlgeschlagen', life: 3000 })
}
}
async function deleteQuickText(id: string) {
try {
await api.delete(`/quicktexts/${id}`)
toast.add({ severity: 'success', summary: 'Gelöscht', detail: 'Quick-Text entfernt', life: 2000 })
await loadQuickTexts()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Löschen fehlgeschlagen', life: 3000 })
}
}
function appendQuickText(text: string) {
if (messageText.value && !messageText.value.endsWith('\n')) {
messageText.value += '\n'
}
messageText.value += text
}
function insertTaskId(taskId: string) {
const task = tasks.value.find(t => t._id === taskId)
if (task) {
const insertText = `[${task.title}] (${taskId})`
if (messageText.value && !messageText.value.endsWith('\n')) {
messageText.value += '\n'
}
messageText.value += insertText
// Checkbox ebenfalls anhaken
if (!selectedTasks.value.includes(taskId)) {
selectedTasks.value.push(taskId)
}
}
}
function toggleTask(taskId: string) {
const idx = selectedTasks.value.indexOf(taskId)
if (idx >= 0) {
selectedTasks.value.splice(idx, 1)
} else {
selectedTasks.value.push(taskId)
}
}
// --- Audio Recording ---
async function toggleRecording() {
if (recording.value) {
stopRecording()
} else {
await startRecording()
}
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' })
audioChunks = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) audioChunks.push(event.data)
}
mediaRecorder.onstop = async () => {
stream.getTracks().forEach(track => track.stop())
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' })
await transcribeAudio(audioBlob)
}
mediaRecorder.start()
recording.value = true
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Mikrofon-Zugriff verweigert', life: 3000 })
}
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop()
recording.value = false
}
async function transcribeAudio(blob: Blob) {
transcribing.value = true
try {
const formData = new FormData()
formData.append('audio', blob, 'recording.webm')
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
const token = localStorage.getItem('ams_token')
const response = await fetch(`${apiBase}/transcribe`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
})
const data = await response.json()
if (data.error) {
toast.add({ severity: 'error', summary: 'Transkription', detail: data.error, life: 5000 })
return
}
if (data.text) {
if (messageText.value && !messageText.value.endsWith('\n')) messageText.value += ' '
messageText.value += data.text
}
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Transkription fehlgeschlagen', life: 3000 })
} finally {
transcribing.value = false
}
}
async function sendMessage() {
if (!messageText.value.trim()) {
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Nachricht darf nicht leer sein', life: 2000 })
return
}
sending.value = true
try {
// Kontext sammeln
const selectedProjectObj = projects.value.find(p => p._id === selectedProject.value)
const selectedAgentObj = agents.value.find(a => a._id === selectedAgent.value)
const linkedTitles = selectedTasks.value.map(id => tasks.value.find(t => t._id === id)?.title || id)
// 1. Auftrag in DB speichern
await api.post('/agent-tasks', {
message: messageText.value.trim(),
projectId: selectedProject.value || undefined,
projectName: selectedProjectObj?.name || undefined,
agentId: selectedAgent.value || undefined,
agentName: selectedAgentObj ? `${selectedAgentObj.emoji} ${selectedAgentObj.name}` : undefined,
linkedTaskIds: selectedTasks.value,
linkedTaskTitles: linkedTitles,
})
// 2. Optional: Telegram-Benachrichtigung
try {
await api.post('/messaging/telegram', { text: messageText.value.trim() })
} catch {
// Telegram ist optional — kein Fehler wenn nicht konfiguriert
}
// 3. OpenClaw Wake — Agent sofort wecken (via Backend-Proxy)
try {
await api.post('/agent-tasks/wake', {})
} catch {
// Wake ist optional
}
toast.add({ severity: 'success', summary: 'Auftrag erstellt', detail: 'Auftrag gespeichert und Agent benachrichtigt', life: 3000 })
messageText.value = ''
selectedTasks.value = []
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Senden fehlgeschlagen'
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
} finally {
sending.value = false
}
}
function onProjectChange() {
selectedTasks.value = []
if (selectedProject.value) {
loadProjectTasks(selectedProject.value)
} else {
loadTasks()
}
}
// --- WebSocket Live-Updates ---
function reloadTasks() {
if (selectedProject.value) {
loadProjectTasks(selectedProject.value)
} else {
loadTasks()
}
}
function onTaskStatusChanged(data: unknown) {
const { taskId, newStatus } = data as { taskId: string; newStatus: string }
if (newStatus === 'in_progress') {
// Task wurde auf in_progress gesetzt — neu laden damit er erscheint
reloadTasks()
} else {
// Task ist nicht mehr in_progress — entfernen
tasks.value = tasks.value.filter(t => t._id !== taskId)
selectedTasks.value = selectedTasks.value.filter(id => id !== taskId)
}
}
function onTaskUpdated(data: unknown) {
const { taskId, changes } = data as { taskId: string; changes: Record<string, unknown> }
const task = tasks.value.find(t => t._id === taskId)
if (task) {
// Status-Änderung über update-Route
if (changes.status && String(changes.status) !== 'in_progress') {
tasks.value = tasks.value.filter(t => t._id !== taskId)
selectedTasks.value = selectedTasks.value.filter(id => id !== taskId)
return
}
// Andere Felder aktualisieren
if (changes.title) task.title = String(changes.title)
if (changes.priority) task.priority = String(changes.priority)
if (changes.status) task.status = String(changes.status)
} else if (changes.status === 'in_progress') {
reloadTasks()
}
}
function onTaskCreated() {
reloadTasks()
}
function onTaskDeleted(data: unknown) {
const { taskId } = data as { taskId: string }
tasks.value = tasks.value.filter(t => t._id !== taskId)
selectedTasks.value = selectedTasks.value.filter(id => id !== taskId)
}
onMounted(() => {
loadProjects()
loadAgents()
loadQuickTexts()
reloadTasks()
// WebSocket-Events registrieren
wsStore.on('task:status_changed', onTaskStatusChanged)
wsStore.on('task:updated', onTaskUpdated)
wsStore.on('task:created', onTaskCreated)
wsStore.on('task:deleted', onTaskDeleted)
})
onUnmounted(() => {
wsStore.off('task:status_changed', onTaskStatusChanged)
wsStore.off('task:updated', onTaskUpdated)
wsStore.off('task:created', onTaskCreated)
wsStore.off('task:deleted', onTaskDeleted)
})
</script>
<template>
<div class="agent-task-page">
<div class="page-header">
<h1><i class="pi pi-send" style="margin-right: 0.5rem;"></i>Aufgabe stellen</h1>
</div>
<!-- Kontext-Zeile: Projekt + Agent -->
<div class="context-bar">
<div class="context-item">
<label>Projekt</label>
<Dropdown
v-model="selectedProject"
:options="projectOptions"
optionLabel="label"
optionValue="value"
placeholder="Alle Projekte"
showClear
class="dropdown-dark"
@change="onProjectChange"
/>
</div>
<div class="context-item">
<label>Agent</label>
<Dropdown
v-model="selectedAgent"
:options="agentOptions"
optionLabel="label"
optionValue="value"
placeholder="Agent wählen..."
showClear
class="dropdown-dark"
/>
</div>
</div>
<!-- 3-Spalten Layout -->
<div class="three-col">
<!-- Links: Tasks -->
<div class="col-tasks">
<div class="col-header">
<h3><i class="pi pi-list-check"></i> Tasks ({{ filteredTasks.length }})</h3>
</div>
<div class="task-list-scroll">
<div v-if="filteredTasks.length > 0" class="task-list">
<div
v-for="task in filteredTasks"
:key="task._id"
class="task-list-item"
:class="{ selected: selectedTasks.includes(task._id) }"
@click="toggleTask(task._id)"
>
<i :class="selectedTasks.includes(task._id) ? 'pi pi-check-square' : 'pi pi-stop'"
:style="{ color: selectedTasks.includes(task._id) ? '#818cf8' : 'rgba(255,255,255,0.25)', fontSize: '0.95rem' }"></i>
<span class="task-prio" :class="'prio-' + String(task.priority || 'medium')">{{ String(task.priority || 'medium').substring(0, 3).toUpperCase() }}</span>
<span class="task-status-badge">{{ formatStatus(task.status) }}</span>
<span class="task-title clickable" @click.stop="openTaskDialog(task._id)">{{ task.title }}</span>
<button class="icon-btn-sm" @click.stop="insertTaskId(task._id)" title="In Nachricht einfügen">
<i class="pi pi-arrow-right"></i>
</button>
</div>
</div>
<div v-else class="empty-hint">Keine offenen Tasks.</div>
</div>
</div>
<!-- Mitte: Nachricht -->
<div class="col-message">
<div class="col-header">
<h3><i class="pi pi-comment"></i> Nachricht</h3>
</div>
<div class="message-area">
<textarea
v-model="messageText"
placeholder="Nachricht eingeben oder einsprechen..."
class="message-input"
></textarea>
<div class="message-toolbar">
<button
class="toolbar-btn"
:class="{ active: recording, pulse: recording }"
@click="toggleRecording"
:disabled="transcribing"
>
<i :class="recording ? 'pi pi-stop-circle' : 'pi pi-microphone'"></i>
{{ recording ? 'Stop' : transcribing ? 'Transkribiert...' : 'Aufnehmen' }}
</button>
<button
class="toolbar-btn send"
@click="sendMessage"
:disabled="sending || !messageText.trim()"
>
<i :class="sending ? 'pi pi-spin pi-spinner' : 'pi pi-send'"></i>
{{ sending ? 'Sende...' : 'Senden' }}
</button>
</div>
</div>
</div>
<!-- Rechts: Quick-Texte -->
<div class="col-quicktexts">
<div class="col-header">
<h3><i class="pi pi-bolt"></i> Quick-Texte</h3>
<button class="icon-btn" @click="openQuickTextDialog()" title="Neuer Quick-Text">
<i class="pi pi-plus"></i>
</button>
</div>
<div class="quicktext-scroll">
<div v-if="quickTexts.length > 0" class="qt-list">
<div v-for="qt in quickTexts" :key="qt._id" class="quick-text-card">
<div class="qt-content" @click="appendQuickText(qt.text)">
<div class="qt-title">{{ qt.title }}</div>
<div class="qt-preview">{{ qt.text.substring(0, 60) }}{{ qt.text.length > 60 ? '...' : '' }}</div>
</div>
<div class="qt-actions">
<button class="icon-btn-sm" @click.stop="openQuickTextDialog(qt)" title="Bearbeiten">
<i class="pi pi-pencil"></i>
</button>
<button class="icon-btn-sm danger" @click.stop="deleteQuickText(qt._id)" title="Löschen">
<i class="pi pi-trash"></i>
</button>
</div>
</div>
</div>
<div v-else class="empty-hint">Keine Quick-Texte. Klicke + zum Erstellen.</div>
</div>
</div>
</div>
<!-- Task-Detail Dialog -->
<TaskEditDialog
:taskId="taskDialogId"
v-model:visible="taskDialogVisible"
@saved="onTaskDialogSaved"
/>
<!-- Quick-Text Dialog -->
<Dialog
v-model:visible="quickTextDialog"
:header="editingQuickText ? 'Quick-Text bearbeiten' : 'Neuer Quick-Text'"
:modal="true"
:style="{ width: '450px' }"
>
<div class="dialog-form">
<div class="form-group">
<label>Titel</label>
<input v-model="qtTitle" class="form-input" placeholder="z.B. Bug melden" />
</div>
<div class="form-group">
<label>Text</label>
<textarea v-model="qtText" class="form-input" rows="4" placeholder="Der Text der eingefügt wird..."></textarea>
</div>
</div>
<template #footer>
<button class="toolbar-btn" @click="quickTextDialog = false">Abbrechen</button>
<button class="toolbar-btn send" @click="saveQuickText" :disabled="!qtTitle || !qtText">Speichern</button>
</template>
</Dialog>
</div>
</template>
<style scoped>
.agent-task-page {
display: flex;
flex-direction: column;
height: calc(100vh - 2rem);
overflow: hidden;
}
.page-header {
flex-shrink: 0;
}
/* Kontext-Zeile */
.context-bar {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-shrink: 0;
}
.context-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.context-item label {
color: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
}
.dropdown-dark {
min-width: 200px;
}
/* 3-Spalten Layout */
.three-col {
display: grid;
grid-template-columns: 1fr 1.2fr 0.8fr;
gap: 1rem;
flex: 1;
min-height: 0;
overflow: hidden;
}
.col-tasks, .col-message, .col-quicktexts {
display: flex;
flex-direction: column;
min-height: 0;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
overflow: hidden;
}
.col-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.col-header h3 {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.6);
margin: 0;
display: flex;
align-items: center;
gap: 0.4rem;
}
/* Task Liste */
.task-list-scroll {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.task-list {
display: flex;
flex-direction: column;
}
.task-list-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
cursor: pointer;
transition: background 0.15s;
}
.task-list-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.task-list-item.selected {
background: rgba(99, 102, 241, 0.1);
}
.task-prio {
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.03em;
padding: 0.1rem 0.3rem;
border-radius: 3px;
flex-shrink: 0;
min-width: 30px;
text-align: center;
}
.prio-urgent { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
.prio-high { background: rgba(249, 115, 22, 0.2); color: #fdba74; }
.prio-medium { background: rgba(234, 179, 8, 0.2); color: #fde047; }
.prio-low { background: rgba(34, 197, 94, 0.2); color: #86efac; }
.task-status-badge {
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.03em;
padding: 0.1rem 0.35rem;
border-radius: 3px;
flex-shrink: 0;
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
white-space: nowrap;
}
.task-title {
flex: 1;
color: rgba(255, 255, 255, 0.8);
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-title.clickable {
cursor: pointer;
transition: color 0.15s;
}
.task-title.clickable:hover {
color: #818cf8;
text-decoration: underline;
}
/* Nachricht */
.col-message {
display: flex;
flex-direction: column;
}
.message-area {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.message-input {
flex: 1;
width: 100%;
padding: 0.75rem;
background: transparent;
border: none;
color: #fff;
font-size: 0.9rem;
font-family: inherit;
resize: none;
outline: none;
min-height: 0;
}
.message-input::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.message-toolbar {
display: flex;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
gap: 0.5rem;
}
.toolbar-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: rgba(255, 255, 255, 0.65);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.toolbar-btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
.toolbar-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.toolbar-btn.active { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.4); color: #fca5a5; }
.toolbar-btn.send { background: rgba(99, 102, 241, 0.2); border-color: rgba(99, 102, 241, 0.3); color: #a5b4fc; }
.toolbar-btn.send:hover:not(:disabled) { background: rgba(99, 102, 241, 0.35); color: #fff; }
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
}
.pulse { animation: pulse-glow 1.5s infinite; }
/* Quick-Texte */
.quicktext-scroll {
flex: 1;
overflow-y: auto;
min-height: 0;
padding: 0.5rem;
}
.qt-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.quick-text-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
transition: all 0.15s;
}
.quick-text-card:hover {
border-color: rgba(99, 102, 241, 0.3);
background: rgba(255, 255, 255, 0.06);
}
.qt-content {
padding: 0.5rem 0.65rem;
cursor: pointer;
}
.qt-title {
font-weight: 600;
color: #fff;
font-size: 0.82rem;
margin-bottom: 0.15rem;
}
.qt-preview {
color: rgba(255, 255, 255, 0.4);
font-size: 0.72rem;
line-height: 1.3;
}
.qt-actions {
display: flex;
gap: 0.2rem;
padding: 0.15rem 0.5rem 0.4rem;
}
.icon-btn {
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.25);
border-radius: 6px;
color: #a5b4fc;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s;
}
.icon-btn:hover { background: rgba(99, 102, 241, 0.3); color: #fff; }
.icon-btn-sm {
background: none;
border: none;
color: rgba(255, 255, 255, 0.3);
cursor: pointer;
padding: 0.2rem;
font-size: 0.75rem;
transition: color 0.15s;
}
.icon-btn-sm:hover { color: #fff; }
.icon-btn-sm.danger:hover { color: #ef4444; }
.empty-hint {
color: rgba(255, 255, 255, 0.3);
font-size: 0.8rem;
padding: 1rem;
text-align: center;
}
/* Dialog Dark Theme - moved to global style block below */
/* Dialog Form */
.dialog-form { display: flex; flex-direction: column; gap: 1rem; }
.form-group { display: flex; flex-direction: column; gap: 0.3rem; }
.form-group label { color: rgba(255, 255, 255, 0.7); font-size: 0.85rem; font-weight: 500; }
.form-input {
padding: 0.5rem 0.65rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
font-family: inherit;
outline: none;
}
.form-input:focus { border-color: #6366f1; }
.form-input::placeholder { color: rgba(255, 255, 255, 0.3); }
/* Scrollbar styling */
.task-list-scroll::-webkit-scrollbar,
.quicktext-scroll::-webkit-scrollbar {
width: 4px;
}
.task-list-scroll::-webkit-scrollbar-thumb,
.quicktext-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.task-list-scroll::-webkit-scrollbar-thumb:hover,
.quicktext-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Responsive — unter 900px: Single-Column */
@media (max-width: 900px) {
.agent-task-page {
height: auto;
overflow: auto;
}
.three-col {
grid-template-columns: 1fr;
overflow: visible;
}
.task-list-scroll {
max-height: 300px;
}
.quicktext-scroll {
max-height: 250px;
}
.col-message {
min-height: 250px;
}
.context-bar {
flex-direction: column;
}
.dropdown-dark {
min-width: 100%;
}
}
</style>
<!-- Dialog Dark Theme now in global style.css -->

View File

@@ -0,0 +1,677 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import Toast from 'primevue/toast'
import Dialog from 'primevue/dialog'
import { api } from '../api'
interface AgentTask {
_id: string
number?: number
message: string
projectId?: string
projectName?: string
agentId?: string
agentName?: string
linkedTaskIds: string[]
linkedTaskTitles: string[]
status: 'pending' | 'in_progress' | 'done' | 'rejected'
createdBy: string
createdByName: string
result?: string
createdAt: string
updatedAt: string
}
const toast = useToast()
const tasks = ref<AgentTask[]>([])
const loading = ref(true)
const statusFilter = ref<string>('all')
// Edit state
const editDialogVisible = ref(false)
const editTask = ref<AgentTask | null>(null)
const editMessage = ref('')
const editLoading = ref(false)
// Detail state
const detailDialogVisible = ref(false)
const detailTask = ref<AgentTask | null>(null)
const filteredTasks = computed(() => {
if (statusFilter.value === 'all') return tasks.value
return tasks.value.filter(t => t.status === statusFilter.value)
})
const statusCounts = computed(() => {
const counts: Record<string, number> = { all: tasks.value.length }
for (const t of tasks.value) {
counts[t.status] = (counts[t.status] || 0) + 1
}
return counts
})
async function loadTasks() {
loading.value = true
try {
const res = await api.get<{ tasks: AgentTask[] }>('/agent-tasks')
tasks.value = res.tasks || []
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Agent-Tasks konnten nicht geladen werden', life: 3000 })
} finally {
loading.value = false
}
}
function openDetail(task: AgentTask) {
detailTask.value = task
detailDialogVisible.value = true
}
function openEdit(task: AgentTask) {
editTask.value = task
editMessage.value = task.message
editDialogVisible.value = true
}
async function saveEdit() {
if (!editTask.value) return
editLoading.value = true
try {
await api.put(`/agent-tasks/${editTask.value._id}`, { message: editMessage.value })
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Auftrag aktualisiert', life: 3000 })
editDialogVisible.value = false
await loadTasks()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Speichern fehlgeschlagen', life: 3000 })
} finally {
editLoading.value = false
}
}
async function duplicateTask(task: AgentTask) {
try {
await api.post('/agent-tasks', {
message: task.message,
projectId: task.projectId,
projectName: task.projectName,
agentId: task.agentId,
agentName: task.agentName,
linkedTaskIds: task.linkedTaskIds,
linkedTaskTitles: task.linkedTaskTitles,
})
toast.add({ severity: 'success', summary: 'Kopiert', detail: 'Neuer pending Auftrag erstellt — bearbeite ihn jetzt', life: 4000 })
await loadTasks()
// Open the new pending task for editing
const newTask = tasks.value.find(t => t.status === 'pending' && t.message === task.message)
if (newTask) openEdit(newTask)
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Kopieren fehlgeschlagen', life: 3000 })
}
}
async function resendTask(task: AgentTask) {
try {
await api.post('/agent-tasks/wake', {})
toast.add({ severity: 'success', summary: 'Gesendet', detail: 'Agent wurde geweckt', life: 3000 })
} catch {
// Wake is optional
toast.add({ severity: 'info', summary: 'Gespeichert', detail: 'Auftrag erstellt (Wake fehlgeschlagen)', life: 3000 })
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function statusLabel(status: string) {
const map: Record<string, string> = {
pending: 'Ausstehend',
in_progress: 'In Bearbeitung',
done: 'Erledigt',
rejected: 'Abgelehnt',
}
return map[status] || status
}
function statusClass(status: string) {
const map: Record<string, string> = {
pending: 'status-pending',
in_progress: 'status-progress',
done: 'status-done',
rejected: 'status-rejected',
}
return map[status] || ''
}
onMounted(loadTasks)
</script>
<template>
<div class="overview-view">
<Toast />
<div class="page-header">
<h2><i class="pi pi-list-check"></i> Agent-Aufträge</h2>
<button class="dialog-btn" @click="loadTasks" :disabled="loading">
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'"></i>
Aktualisieren
</button>
</div>
<!-- Status Filter -->
<div class="filter-bar">
<button
v-for="s in ['all', 'pending', 'in_progress', 'done', 'rejected']"
:key="s"
class="filter-btn"
:class="{ active: statusFilter === s }"
@click="statusFilter = s"
>
{{ s === 'all' ? 'Alle' : statusLabel(s) }}
<span class="filter-count">{{ statusCounts[s] || 0 }}</span>
</button>
</div>
<!-- Task List -->
<div v-if="loading" class="loading-state">
<i class="pi pi-spin pi-spinner"></i> Lade Aufträge...
</div>
<div v-else-if="filteredTasks.length === 0" class="empty-state">
Keine Aufträge gefunden.
</div>
<div v-else class="task-list">
<div v-for="task in filteredTasks" :key="task._id" class="task-card">
<div class="task-header">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span v-if="task.number" class="task-number">#{{ task.number }}</span>
<span class="status-badge" :class="statusClass(task.status)">
{{ statusLabel(task.status) }}
</span>
</div>
<span class="task-date">{{ formatDate(task.createdAt) }}</span>
</div>
<div class="task-message" @click="openDetail(task)">
{{ task.message }}
</div>
<div v-if="task.projectName" class="task-meta">
<i class="pi pi-folder"></i> {{ task.projectName }}
</div>
<div v-if="task.linkedTaskTitles?.length" class="task-meta">
<i class="pi pi-link"></i> {{ task.linkedTaskTitles.join(', ') }}
</div>
<div v-if="task.result" class="task-result">
<i class="pi pi-check-circle"></i> {{ task.result }}
</div>
<div class="task-actions">
<button
v-if="task.status === 'pending'"
class="action-btn edit"
@click="openEdit(task)"
>
<i class="pi pi-pencil"></i> Bearbeiten
</button>
<button
v-if="task.status === 'pending'"
class="action-btn send"
@click="resendTask(task)"
>
<i class="pi pi-send"></i> Erneut senden
</button>
<button
v-if="task.status === 'done' || task.status === 'rejected'"
class="action-btn copy"
@click="duplicateTask(task)"
>
<i class="pi pi-copy"></i> Kopie erstellen
</button>
<button class="action-btn detail" @click="openDetail(task)">
<i class="pi pi-eye"></i> Details
</button>
</div>
</div>
</div>
<!-- Edit Dialog -->
<Dialog
v-model:visible="editDialogVisible"
header="Auftrag bearbeiten"
:modal="true"
:style="{ width: '36rem' }"
>
<div class="edit-form">
<div class="form-group">
<label>Nachricht</label>
<textarea
v-model="editMessage"
class="form-input"
rows="6"
placeholder="Auftrag beschreiben..."
></textarea>
</div>
<div v-if="editTask?.linkedTaskTitles?.length" class="form-group">
<label>Verknüpfte Tasks</label>
<div class="linked-tags">
<span v-for="title in editTask.linkedTaskTitles" :key="title" class="linked-tag">
{{ title }}
</span>
</div>
</div>
</div>
<template #footer>
<button class="dialog-btn" @click="editDialogVisible = false">Abbrechen</button>
<button
class="dialog-btn primary"
:disabled="!editMessage.trim() || editLoading"
@click="saveEdit"
>
<i v-if="editLoading" class="pi pi-spin pi-spinner"></i>
Speichern
</button>
</template>
</Dialog>
<!-- Detail Dialog -->
<Dialog
v-model:visible="detailDialogVisible"
header="Auftragsdetails"
:modal="true"
:style="{ width: '40rem' }"
>
<div v-if="detailTask" class="detail-content">
<div class="detail-row">
<span class="detail-label">Status</span>
<span class="status-badge" :class="statusClass(detailTask.status)">
{{ statusLabel(detailTask.status) }}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Erstellt</span>
<span>{{ formatDate(detailTask.createdAt) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Von</span>
<span>{{ detailTask.createdByName }}</span>
</div>
<div v-if="detailTask.projectName" class="detail-row">
<span class="detail-label">Projekt</span>
<span>{{ detailTask.projectName }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Nachricht</span>
<div class="detail-message">{{ detailTask.message }}</div>
</div>
<div v-if="detailTask.linkedTaskTitles?.length" class="detail-row">
<span class="detail-label">Verknüpft</span>
<div class="linked-tags">
<span v-for="title in detailTask.linkedTaskTitles" :key="title" class="linked-tag">
{{ title }}
</span>
</div>
</div>
<div v-if="detailTask.result" class="detail-row">
<span class="detail-label">Ergebnis</span>
<div class="detail-result">{{ detailTask.result }}</div>
</div>
</div>
<template #footer>
<button class="dialog-btn" @click="detailDialogVisible = false">Schließen</button>
<button
v-if="detailTask?.status === 'pending'"
class="dialog-btn primary"
@click="detailDialogVisible = false; openEdit(detailTask!)"
>
<i class="pi pi-pencil"></i> Bearbeiten
</button>
<button
v-if="detailTask?.status === 'done' || detailTask?.status === 'rejected'"
class="dialog-btn primary"
@click="detailDialogVisible = false; duplicateTask(detailTask!)"
>
<i class="pi pi-copy"></i> Kopie erstellen
</button>
</template>
</Dialog>
</div>
</template>
<style scoped>
.overview-view {
padding: 1.5rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.25rem;
}
.page-header h2 {
margin: 0;
color: #e0e0e0;
font-size: 1.4rem;
}
.page-header h2 i {
margin-right: 0.5rem;
}
/* Filter Bar */
.filter-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.4rem 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 0.4rem;
}
.filter-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.filter-btn.active {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.3);
color: #a5b4fc;
}
.filter-count {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 0 0.4rem;
font-size: 0.7rem;
min-width: 1.2rem;
text-align: center;
}
.filter-btn.active .filter-count {
background: rgba(99, 102, 241, 0.25);
}
/* Task List */
.task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 1rem 1.25rem;
transition: all 0.15s;
}
.task-card:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.12);
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.6rem;
}
.task-number {
color: rgba(255, 255, 255, 0.35);
font-size: 0.8rem;
font-weight: 600;
}
.task-date {
color: rgba(255, 255, 255, 0.4);
font-size: 0.8rem;
}
/* Status Badges */
.status-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
.status-pending {
background: rgba(234, 179, 8, 0.15);
color: #facc15;
border: 1px solid rgba(234, 179, 8, 0.25);
}
.status-progress {
background: rgba(99, 102, 241, 0.15);
color: #a5b4fc;
border: 1px solid rgba(99, 102, 241, 0.25);
}
.status-done {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.25);
}
.status-rejected {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.25);
}
.task-message {
color: #e0e0e0;
font-size: 0.9rem;
line-height: 1.5;
margin-bottom: 0.5rem;
cursor: pointer;
white-space: pre-wrap;
}
.task-message:hover {
color: #fff;
}
.task-meta {
color: rgba(255, 255, 255, 0.4);
font-size: 0.8rem;
margin-bottom: 0.3rem;
}
.task-meta i {
margin-right: 0.3rem;
}
.task-result {
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.15);
border-radius: 8px;
padding: 0.5rem 0.75rem;
color: rgba(74, 222, 128, 0.85);
font-size: 0.85rem;
margin: 0.5rem 0;
}
.task-result i {
margin-right: 0.3rem;
}
.task-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.action-btn {
padding: 0.35rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.action-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
.action-btn.edit:hover { color: #a5b4fc; border-color: rgba(99, 102, 241, 0.3); }
.action-btn.send:hover { color: #4ade80; border-color: rgba(34, 197, 94, 0.3); }
.action-btn.copy:hover { color: #facc15; border-color: rgba(234, 179, 8, 0.3); }
.action-btn.detail:hover { color: #e0e0e0; }
/* Loading & Empty */
.loading-state, .empty-state {
text-align: center;
padding: 3rem;
color: rgba(255, 255, 255, 0.4);
}
/* Dialog styles */
.dialog-btn {
padding: 0.45rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.8);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.dialog-btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
.dialog-btn.primary {
background: rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.3);
color: #a5b4fc;
}
.dialog-btn.primary:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.35);
color: #fff;
}
.dialog-btn:disabled { opacity: 0.35; cursor: not-allowed; }
/* Edit Form */
.edit-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.form-group label {
color: rgba(255, 255, 255, 0.7);
font-size: 0.85rem;
font-weight: 500;
}
.form-input {
padding: 0.5rem 0.65rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
font-family: inherit;
outline: none;
resize: vertical;
}
.form-input:focus { border-color: #6366f1; }
.form-input::placeholder { color: rgba(255, 255, 255, 0.3); }
.linked-tags {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.linked-tag {
padding: 0.2rem 0.6rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.7);
}
/* Detail Dialog */
.detail-content {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.detail-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.detail-label {
color: rgba(255, 255, 255, 0.5);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-message {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 0.75rem;
color: #e0e0e0;
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
}
.detail-result {
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.15);
border-radius: 8px;
padding: 0.75rem;
color: rgba(74, 222, 128, 0.85);
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
}
</style>

404
src/views/AgentsView.vue Normal file
View File

@@ -0,0 +1,404 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import { useAuthStore } from '../stores/auth'
import { useToast } from 'primevue/usetoast'
import AgentFiles from '../components/AgentFiles.vue'
interface Agent {
_id: string
name: string
emoji: string
role: string
status: 'online' | 'idle' | 'offline' | 'busy'
supervisor?: string
discordId?: string
containerId?: string
containerIp?: string
lastSeen?: string
}
const authStore = useAuthStore()
const toast = useToast()
const agents = ref<Agent[]>([])
const loading = ref(true)
const showAddModal = ref(false)
const newAgent = ref({ name: '', emoji: '🤖', role: '', status: 'offline' as const })
const expandedAgent = ref<string | null>(null)
function toggleFiles(agentId: string) {
expandedAgent.value = expandedAgent.value === agentId ? null : agentId
}
async function loadAgents() {
loading.value = true
try {
const data = await api.get<{ agents: Agent[] }>('/agents')
agents.value = data.agents
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Agents konnten nicht geladen werden', life: 5000 })
} finally {
loading.value = false
}
}
async function addAgent() {
if (!newAgent.value.name || !newAgent.value.role) {
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Name und Rolle sind erforderlich', life: 3000 })
return
}
try {
await api.post('/agents', newAgent.value)
toast.add({ severity: 'success', summary: 'Erfolg', detail: 'Agent erstellt', life: 3000 })
showAddModal.value = false
newAgent.value = { name: '', emoji: '🤖', role: '', status: 'offline' }
await loadAgents()
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
}
}
async function deleteAgent(id: string, name: string) {
if (!confirm(`Agent "${name}" wirklich löschen?`)) return
try {
await api.delete(`/agents/${id}`)
toast.add({ severity: 'success', summary: 'Gelöscht', detail: `Agent ${name} wurde entfernt`, life: 3000 })
await loadAgents()
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
}
}
onMounted(loadAgents)
</script>
<template>
<div>
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center;">
<h1>Agents</h1>
<button v-if="authStore.user?.role === 'admin'" @click="showAddModal = true" class="add-btn">
<i class="pi pi-plus"></i> Agent hinzufügen
</button>
</div>
<div v-if="loading" class="loading">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
</div>
<div v-else-if="agents.length === 0" class="empty-state">
<i class="pi pi-users" style="font-size: 3rem; color: rgba(255,255,255,0.3);"></i>
<p>Noch keine Agents vorhanden</p>
<button v-if="authStore.user?.role === 'admin'" @click="showAddModal = true" class="add-btn">
Ersten Agent erstellen
</button>
</div>
<div v-else class="agents-grid">
<div v-for="agent in agents" :key="agent._id" class="agent-wrapper">
<div class="agent-card" @click="toggleFiles(agent._id)">
<div class="agent-avatar">{{ agent.emoji }}</div>
<div class="agent-info">
<div class="agent-name">{{ agent.name }}</div>
<div class="agent-role">{{ agent.role }}</div>
<div v-if="agent.containerIp" class="agent-ip">{{ agent.containerIp }}</div>
</div>
<div class="agent-actions">
<button class="files-btn" :class="{ active: expandedAgent === agent._id }" title="Workspace Dateien" @click.stop="toggleFiles(agent._id)">
<i class="pi pi-file"></i>
</button>
<div class="agent-status" :class="agent.status">
{{ agent.status }}
</div>
<button
v-if="authStore.user?.role === 'admin'"
@click.stop="deleteAgent(agent._id, agent.name)"
class="delete-btn"
title="Löschen"
>
<i class="pi pi-trash"></i>
</button>
</div>
</div>
<div v-if="expandedAgent === agent._id" class="agent-files-panel">
<AgentFiles :agentId="agent._id" />
</div>
</div>
</div>
<!-- Add Agent Modal -->
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
<div class="modal">
<h2>Neuer Agent</h2>
<div class="form-field">
<label>Name</label>
<input v-model="newAgent.name" type="text" placeholder="z.B. Kernel Panic" />
</div>
<div class="form-field">
<label>Emoji</label>
<input v-model="newAgent.emoji" type="text" placeholder="🤖" style="width: 80px;" />
</div>
<div class="form-field">
<label>Rolle</label>
<input v-model="newAgent.role" type="text" placeholder="z.B. Backend Dev" />
</div>
<div class="form-field">
<label>Status</label>
<select v-model="newAgent.status">
<option value="offline">Offline</option>
<option value="online">Online</option>
<option value="idle">Idle</option>
<option value="busy">Busy</option>
</select>
</div>
<div class="modal-actions">
<button @click="showAddModal = false" class="cancel-btn">Abbrechen</button>
<button @click="addAgent" class="submit-btn">Erstellen</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.agents-grid {
display: grid;
gap: 1rem;
}
.agent-wrapper {
border-radius: 12px;
overflow: hidden;
}
.agent-card {
cursor: pointer;
transition: background 0.15s;
display: flex;
align-items: center;
gap: 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1rem 1.25rem;
}
.agent-avatar {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(99, 102, 241, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.agent-info {
flex: 1;
}
.agent-name {
font-weight: 600;
color: #fff;
}
.agent-role {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
}
.agent-ip {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
font-family: monospace;
}
.agent-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.agent-status {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.agent-status.online {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
.agent-status.idle {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.agent-status.offline {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.agent-status.busy {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
}
.add-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: none;
border-radius: 8px;
color: #fff;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.add-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.delete-btn {
padding: 0.5rem;
background: transparent;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
color: #f87171;
cursor: pointer;
transition: background 0.2s;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.2);
}
.files-btn {
padding: 0.5rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.2s;
}
.files-btn:hover {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
border-color: rgba(99, 102, 241, 0.3);
}
.files-btn.active {
background: rgba(99, 102, 241, 0.3);
color: #818cf8;
border-color: rgba(99, 102, 241, 0.4);
}
.agent-files-panel {
padding: 1rem;
background: rgba(255, 255, 255, 0.02);
border-top: 1px solid rgba(255, 255, 255, 0.08);
overflow: visible;
}
.loading, .empty-state {
text-align: center;
padding: 4rem 2rem;
color: rgba(255, 255, 255, 0.6);
}
.empty-state p {
margin: 1rem 0;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 2rem;
width: 100%;
max-width: 400px;
}
.modal h2 {
margin: 0 0 1.5rem;
color: #fff;
}
.form-field {
margin-bottom: 1rem;
}
.form-field label {
display: block;
margin-bottom: 0.5rem;
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
}
.form-field input, .form-field select {
width: 100%;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
font-size: 1rem;
}
.form-field input:focus, .form-field select:focus {
outline: none;
border-color: #6366f1;
}
.modal-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.cancel-btn {
flex: 1;
padding: 0.75rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
}
.submit-btn {
flex: 1;
padding: 0.75rem;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: none;
border-radius: 8px;
color: #fff;
font-weight: 600;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,453 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
import Dialog from 'primevue/dialog'
const toast = useToast()
const authStore = useAuthStore()
interface Container {
ID: string
Names: string
Image: string
State: string
Status: string
Ports: string
Networks: string
CreatedAt: string
RunningFor: string
}
const containers = ref<Container[]>([])
const loading = ref(true)
const actionLoading = ref<string | null>(null)
// Log viewer
const showLogDialog = ref(false)
const logContainer = ref<Container | null>(null)
const logContent = ref('')
const logLoading = ref(false)
const logTail = ref(100)
// Auto-refresh
let refreshTimer: ReturnType<typeof setInterval> | null = null
const sortedContainers = computed(() => {
return [...containers.value].sort((a, b) => {
// Running first
if (a.State === 'running' && b.State !== 'running') return -1
if (b.State === 'running' && a.State !== 'running') return 1
return a.Names.localeCompare(b.Names)
})
})
const stats = computed(() => ({
total: containers.value.length,
running: containers.value.filter(c => c.State === 'running').length,
stopped: containers.value.filter(c => c.State !== 'running').length
}))
async function loadContainers() {
try {
const res = await api.get<{ containers: Container[] }>('/docker/containers')
containers.value = res.containers || []
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Container konnten nicht geladen werden', life: 5000 })
} finally {
loading.value = false
}
}
async function containerAction(id: string, action: 'start' | 'stop' | 'restart') {
actionLoading.value = id + action
try {
await api.post(`/docker/containers/${id}/${action}`)
toast.add({ severity: 'success', summary: 'Erfolg', detail: `Container ${action}`, life: 3000 })
await loadContainers()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
} finally {
actionLoading.value = null
}
}
async function viewLogs(container: Container) {
logContainer.value = container
showLogDialog.value = true
await fetchLogs()
}
async function fetchLogs() {
if (!logContainer.value) return
logLoading.value = true
try {
const res = await api.get<{ logs: string }>(`/docker/containers/${logContainer.value.ID}/logs?tail=${logTail.value}`)
logContent.value = res.logs || 'Keine Logs vorhanden'
} catch {
logContent.value = 'Fehler beim Laden der Logs'
} finally {
logLoading.value = false
}
}
function getStateClass(state: string): string {
switch (state) {
case 'running': return 'state-running'
case 'exited': return 'state-exited'
case 'created': return 'state-created'
case 'restarting': return 'state-restarting'
default: return 'state-unknown'
}
}
function getShortName(names: string): string {
return names.replace(/^\//, '')
}
function getShortImage(image: string): string {
// Shorten registry paths
return image.replace(/^registry\.agentenbude\.de\/agent-management\//, '')
}
onMounted(() => {
loadContainers()
refreshTimer = setInterval(loadContainers, 15000)
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<template>
<div>
<div class="page-header">
<h1><i class="pi pi-box"></i> Container</h1>
</div>
<!-- Stats -->
<div class="container-stats">
<div class="stat-card">
<span class="stat-number">{{ stats.total }}</span>
<span class="stat-label">Gesamt</span>
</div>
<div class="stat-card running">
<span class="stat-number">{{ stats.running }}</span>
<span class="stat-label">Laufend</span>
</div>
<div class="stat-card stopped">
<span class="stat-number">{{ stats.stopped }}</span>
<span class="stat-label">Gestoppt</span>
</div>
</div>
<div v-if="loading" class="loading">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
</div>
<div v-else-if="authStore.user?.role !== 'admin'" class="empty-state">
<i class="pi pi-lock"></i>
<p>Nur Admins können Container verwalten.</p>
</div>
<div v-else class="container-list">
<div
v-for="c in sortedContainers"
:key="c.ID"
class="container-card"
:class="getStateClass(c.State)"
>
<div class="container-header">
<div class="container-info">
<span class="container-state-dot" :class="getStateClass(c.State)"></span>
<h3>{{ getShortName(c.Names) }}</h3>
</div>
<div class="container-actions">
<button
v-if="c.State !== 'running'"
class="action-btn start"
:disabled="actionLoading === c.ID + 'start'"
@click="containerAction(c.ID, 'start')"
title="Starten"
>
<i :class="actionLoading === c.ID + 'start' ? 'pi pi-spin pi-spinner' : 'pi pi-play'"></i>
</button>
<button
v-if="c.State === 'running'"
class="action-btn stop"
:disabled="actionLoading === c.ID + 'stop'"
@click="containerAction(c.ID, 'stop')"
title="Stoppen"
>
<i :class="actionLoading === c.ID + 'stop' ? 'pi pi-spin pi-spinner' : 'pi pi-stop-circle'"></i>
</button>
<button
class="action-btn restart"
:disabled="actionLoading === c.ID + 'restart'"
@click="containerAction(c.ID, 'restart')"
title="Neustarten"
>
<i :class="actionLoading === c.ID + 'restart' ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'"></i>
</button>
<button class="action-btn logs" @click="viewLogs(c)" title="Logs">
<i class="pi pi-file"></i>
</button>
</div>
</div>
<div class="container-details">
<div class="detail">
<span class="detail-label">Image</span>
<span class="detail-value mono">{{ getShortImage(c.Image) }}</span>
</div>
<div class="detail">
<span class="detail-label">Status</span>
<span class="detail-value">{{ c.Status }}</span>
</div>
<div class="detail" v-if="c.Ports">
<span class="detail-label">Ports</span>
<span class="detail-value mono">{{ c.Ports }}</span>
</div>
<div class="detail" v-if="c.Networks">
<span class="detail-label">Netzwerk</span>
<span class="detail-value mono">{{ c.Networks }}</span>
</div>
</div>
</div>
</div>
<!-- Log Dialog -->
<Dialog
v-model:visible="showLogDialog"
:header="'Logs: ' + (logContainer ? getShortName(logContainer.Names) : '')"
:style="{ width: '90vw', maxWidth: '900px' }"
:contentStyle="{ padding: 0 }"
modal
appendTo="self"
>
<div class="log-controls">
<label>Zeilen:</label>
<select v-model="logTail" @change="fetchLogs" class="log-tail-select">
<option :value="50">50</option>
<option :value="100">100</option>
<option :value="500">500</option>
<option :value="1000">1000</option>
</select>
<button class="action-btn refresh" @click="fetchLogs" :disabled="logLoading">
<i :class="logLoading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'"></i> Aktualisieren
</button>
</div>
<pre class="log-content" v-if="!logLoading">{{ logContent }}</pre>
<div v-else class="log-loading">
<i class="pi pi-spin pi-spinner"></i> Lade Logs...
</div>
</Dialog>
</div>
</template>
<style scoped>
.container-stats {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
flex: 1;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1rem;
text-align: center;
}
.stat-card.running { border-color: rgba(34, 197, 94, 0.3); }
.stat-card.stopped { border-color: rgba(239, 68, 68, 0.3); }
.stat-number {
display: block;
font-size: 1.75rem;
font-weight: 700;
color: #fff;
}
.stat-card.running .stat-number { color: #4ade80; }
.stat-card.stopped .stat-number { color: #f87171; }
.stat-label {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.5);
}
.loading {
text-align: center;
padding: 4rem;
color: rgba(255, 255, 255, 0.6);
}
.empty-state {
text-align: center;
padding: 4rem;
color: rgba(255, 255, 255, 0.5);
}
.empty-state i { font-size: 3rem; margin-bottom: 1rem; }
.container-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.container-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
overflow: hidden;
border-left: 3px solid #64748b;
}
.container-card.state-running { border-left-color: #22c55e; }
.container-card.state-exited { border-left-color: #ef4444; }
.container-card.state-created { border-left-color: #f59e0b; }
.container-card.state-restarting { border-left-color: #3b82f6; }
.container-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
}
.container-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.container-info h3 {
margin: 0;
font-size: 0.95rem;
color: #fff;
}
.container-state-dot {
font-size: 0.6rem;
}
.container-state-dot.state-running { color: #22c55e; }
.container-state-dot.state-exited { color: #ef4444; }
.container-state-dot.state-created { color: #f59e0b; }
.container-state-dot.state-restarting { color: #3b82f6; }
.container-actions {
display: flex;
gap: 0.4rem;
}
.action-btn {
padding: 0.4rem 0.6rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.action-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
.action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.action-btn.start:hover { border-color: #22c55e; color: #22c55e; }
.action-btn.stop:hover { border-color: #ef4444; color: #ef4444; }
.action-btn.restart:hover { border-color: #3b82f6; color: #3b82f6; }
.action-btn.logs:hover { border-color: #a78bfa; color: #a78bfa; }
.container-details {
padding: 0 1rem 0.75rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
}
.detail {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.detail-label {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.detail-value {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
}
.detail-value.mono {
font-family: monospace;
font-size: 0.75rem;
}
/* Log Dialog */
.log-controls {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.log-controls label {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.6);
}
.log-tail-select {
padding: 0.35rem 0.5rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #fff;
font-size: 0.85rem;
}
.log-tail-select option {
background: #1a1a2e;
color: #fff;
}
.log-content {
margin: 0;
padding: 1rem;
background: #0d1117;
color: #e6edf3;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.75rem;
line-height: 1.5;
max-height: 60vh;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.log-loading {
padding: 2rem;
text-align: center;
color: rgba(255, 255, 255, 0.5);
}
:deep(.p-dialog) { background: #161b22; border: 1px solid rgba(255, 255, 255, 0.1); }
:deep(.p-dialog-header) { background: #161b22; color: #e6edf3; border-bottom: 1px solid rgba(255, 255, 255, 0.1); }
:deep(.p-dialog-content) { background: #161b22; color: #e6edf3; padding: 0 !important; }
</style>

394
src/views/CronJobsView.vue Normal file
View File

@@ -0,0 +1,394 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import Toast from 'primevue/toast'
import Dialog from 'primevue/dialog'
import { api } from '../api'
interface CronJob {
_id: string
name: string
description: string
intervalMinutes: number
enabled: boolean
lastRun: string | null
lastResult: string | null
nextRun: string | null
timerActive: boolean
createdAt: string
updatedAt: string
}
const toast = useToast()
const jobs = ref<CronJob[]>([])
const loading = ref(true)
// Create/Edit state
const dialogVisible = ref(false)
const editingJob = ref<CronJob | null>(null)
const formName = ref('')
const formDescription = ref('')
const formInterval = ref(30)
const formEnabled = ref(true)
const saving = ref(false)
async function loadJobs() {
loading.value = true
try {
const res = await api.get<{ jobs: CronJob[] }>('/cronjobs')
jobs.value = res.jobs || []
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'CronJobs konnten nicht geladen werden', life: 3000 })
} finally {
loading.value = false
}
}
function openCreate() {
editingJob.value = null
formName.value = ''
formDescription.value = 'Prüft pending Agent-Tasks und weckt den Agenten'
formInterval.value = 30
formEnabled.value = true
dialogVisible.value = true
}
function openEdit(job: CronJob) {
editingJob.value = job
formName.value = job.name
formDescription.value = job.description
formInterval.value = job.intervalMinutes
formEnabled.value = job.enabled
dialogVisible.value = true
}
async function saveJob() {
if (!formName.value.trim()) return
saving.value = true
try {
if (editingJob.value) {
await api.put(`/cronjobs/${editingJob.value._id}`, {
name: formName.value.trim(),
description: formDescription.value,
intervalMinutes: formInterval.value,
enabled: formEnabled.value,
})
toast.add({ severity: 'success', summary: 'Aktualisiert', detail: 'CronJob gespeichert', life: 3000 })
} else {
await api.post('/cronjobs', {
name: formName.value.trim(),
description: formDescription.value,
intervalMinutes: formInterval.value,
enabled: formEnabled.value,
})
toast.add({ severity: 'success', summary: 'Erstellt', detail: 'CronJob erstellt', life: 3000 })
}
dialogVisible.value = false
await loadJobs()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Speichern fehlgeschlagen', life: 3000 })
} finally {
saving.value = false
}
}
async function toggleEnabled(job: CronJob) {
try {
await api.put(`/cronjobs/${job._id}`, { enabled: !job.enabled })
toast.add({ severity: 'success', summary: job.enabled ? 'Deaktiviert' : 'Aktiviert', detail: job.name, life: 2000 })
await loadJobs()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Status konnte nicht geändert werden', life: 3000 })
}
}
async function runNow(job: CronJob) {
try {
await api.post(`/cronjobs/${job._id}/run`, {})
toast.add({ severity: 'success', summary: 'Ausgeführt', detail: `${job.name} manuell gestartet`, life: 3000 })
await loadJobs()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Ausführung fehlgeschlagen', life: 3000 })
}
}
async function deleteJob(job: CronJob) {
if (!confirm(`CronJob "${job.name}" wirklich löschen?`)) return
try {
await api.delete(`/cronjobs/${job._id}`)
toast.add({ severity: 'success', summary: 'Gelöscht', detail: job.name, life: 3000 })
await loadJobs()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Löschen fehlgeschlagen', life: 3000 })
}
}
function formatDate(dateStr: string | null) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
}
function formatInterval(minutes: number) {
if (minutes < 60) return `${minutes} Min.`
const h = Math.floor(minutes / 60)
const m = minutes % 60
return m > 0 ? `${h} Std. ${m} Min.` : `${h} Std.`
}
onMounted(loadJobs)
</script>
<template>
<div class="cronjobs-view">
<Toast />
<div class="page-header">
<h2><i class="pi pi-clock"></i> CronJobs</h2>
<button class="dialog-btn primary" @click="openCreate">
<i class="pi pi-plus"></i> Neuer CronJob
</button>
</div>
<div v-if="loading" class="loading-state">
<i class="pi pi-spin pi-spinner"></i> Lade CronJobs...
</div>
<div v-else-if="jobs.length === 0" class="empty-state">
<i class="pi pi-clock" style="font-size: 2rem; opacity: 0.3"></i>
<p>Keine CronJobs vorhanden.</p>
<button class="dialog-btn primary" @click="openCreate">Jetzt erstellen</button>
</div>
<div v-else class="jobs-list">
<div v-for="job in jobs" :key="job._id" class="job-card" :class="{ disabled: !job.enabled }">
<div class="job-header">
<div class="job-title-row">
<span class="job-indicator" :class="{ active: job.enabled && job.timerActive }"></span>
<h3 class="job-name">{{ job.name }}</h3>
</div>
<div class="job-actions">
<button class="icon-btn" @click="runNow(job)" title="Jetzt ausführen">
<i class="pi pi-play"></i>
</button>
<button class="icon-btn" @click="toggleEnabled(job)" :title="job.enabled ? 'Deaktivieren' : 'Aktivieren'">
<i :class="job.enabled ? 'pi pi-pause' : 'pi pi-play-circle'"></i>
</button>
<button class="icon-btn" @click="openEdit(job)" title="Bearbeiten">
<i class="pi pi-pencil"></i>
</button>
<button class="icon-btn danger" @click="deleteJob(job)" title="Löschen">
<i class="pi pi-trash"></i>
</button>
</div>
</div>
<p v-if="job.description" class="job-desc">{{ job.description }}</p>
<div class="job-meta-grid">
<div class="meta-item">
<span class="meta-label">Intervall</span>
<span class="meta-value">{{ formatInterval(job.intervalMinutes) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Status</span>
<span class="meta-value" :class="job.enabled ? 'text-green' : 'text-red'">
{{ job.enabled ? 'Aktiv' : 'Inaktiv' }}
</span>
</div>
<div class="meta-item">
<span class="meta-label">Letzter Lauf</span>
<span class="meta-value">{{ formatDate(job.lastRun) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Nächster Lauf</span>
<span class="meta-value">{{ formatDate(job.nextRun) }}</span>
</div>
</div>
<div v-if="job.lastResult" class="job-result">
<i class="pi pi-info-circle"></i> {{ job.lastResult }}
</div>
</div>
</div>
<!-- Create/Edit Dialog -->
<Dialog
v-model:visible="dialogVisible"
:header="editingJob ? 'CronJob bearbeiten' : 'Neuer CronJob'"
:modal="true"
:style="{ width: '28rem' }"
>
<div class="edit-form">
<div class="form-group">
<label>Name</label>
<input v-model="formName" class="form-input" placeholder="z.B. Agent-Task Check" />
</div>
<div class="form-group">
<label>Beschreibung</label>
<input v-model="formDescription" class="form-input" placeholder="Was macht dieser Job?" />
</div>
<div class="form-group">
<label>Intervall (Minuten)</label>
<input v-model.number="formInterval" type="number" min="1" class="form-input" />
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" v-model="formEnabled" />
Sofort aktivieren
</label>
</div>
</div>
<template #footer>
<button class="dialog-btn" @click="dialogVisible = false">Abbrechen</button>
<button class="dialog-btn primary" :disabled="!formName.trim() || saving" @click="saveJob">
<i v-if="saving" class="pi pi-spin pi-spinner"></i>
{{ editingJob ? 'Speichern' : 'Erstellen' }}
</button>
</template>
</Dialog>
</div>
</template>
<style scoped>
.cronjobs-view { padding: 1.5rem; }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h2 { margin: 0; color: #e0e0e0; font-size: 1.4rem; }
.page-header h2 i { margin-right: 0.5rem; }
.loading-state, .empty-state {
text-align: center;
padding: 3rem;
color: rgba(255, 255, 255, 0.4);
}
.empty-state p { margin: 1rem 0; }
.jobs-list { display: flex; flex-direction: column; gap: 1rem; }
.job-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 1.25rem;
transition: all 0.15s;
}
.job-card:hover { border-color: rgba(255, 255, 255, 0.15); }
.job-card.disabled { opacity: 0.6; }
.job-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
.job-title-row { display: flex; align-items: center; gap: 0.6rem; }
.job-indicator {
width: 8px; height: 8px; border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
}
.job-indicator.active {
background: #4ade80;
box-shadow: 0 0 8px rgba(74, 222, 128, 0.4);
}
.job-name { margin: 0; color: #e0e0e0; font-size: 1rem; }
.job-actions { display: flex; gap: 0.25rem; }
.icon-btn {
background: none; border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer; padding: 0.35rem;
border-radius: 6px; transition: all 0.15s;
}
.icon-btn:hover { color: #a5b4fc; background: rgba(99, 102, 241, 0.15); }
.icon-btn.danger:hover { color: #f87171; background: rgba(248, 113, 113, 0.15); }
.job-desc { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 0 0 0.75rem 0; }
.job-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.meta-item { display: flex; flex-direction: column; gap: 0.15rem; }
.meta-label { color: rgba(255, 255, 255, 0.4); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
.meta-value { color: #d0d0d0; font-size: 0.85rem; }
.text-green { color: #4ade80; }
.text-red { color: #f87171; }
.job-result {
background: rgba(255, 255, 255, 0.04);
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
margin-top: 0.5rem;
}
.job-result i { margin-right: 0.3rem; }
/* Dialog styles */
.dialog-btn {
padding: 0.45rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.8);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.dialog-btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
.dialog-btn.primary {
background: rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.3);
color: #a5b4fc;
}
.dialog-btn.primary:hover:not(:disabled) { background: rgba(99, 102, 241, 0.35); color: #fff; }
.dialog-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.edit-form { display: flex; flex-direction: column; gap: 1rem; }
.form-group { display: flex; flex-direction: column; gap: 0.3rem; }
.form-group label { color: rgba(255, 255, 255, 0.7); font-size: 0.85rem; font-weight: 500; }
.form-input {
padding: 0.5rem 0.65rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
font-family: inherit;
outline: none;
}
.form-input:focus { border-color: #6366f1; }
.form-input::placeholder { color: rgba(255, 255, 255, 0.3); }
.toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.toggle-label input[type="checkbox"] { accent-color: #6366f1; }
</style>

303
src/views/DashboardView.vue Normal file
View File

@@ -0,0 +1,303 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { api } from '../api'
const authStore = useAuthStore()
interface Stats {
agents: { total: number; online: number }
tasks: { total: number; done: number; inProgress: number; todo: number }
projects: number
logs: { info: number; warn: number; error: number }
}
const stats = ref<Stats>({
agents: { total: 0, online: 0 },
tasks: { total: 0, done: 0, inProgress: 0, todo: 0 },
projects: 0,
logs: { info: 0, warn: 0, error: 0 }
})
const recentLogs = ref<any[]>([])
const loading = ref(true)
async function loadStats() {
try {
const [agentsRes, tasksRes, projectsRes, logsRes] = await Promise.all([
api.get<{ agents: any[] }>('/agents'),
api.get<{ tasks: any[] }>('/tasks'),
api.get<{ projects: any[] }>('/tasks/projects/list'),
api.get<{ logs: any[] }>('/logs?limit=5&hours=24')
])
const agents = agentsRes.agents
const tasks = tasksRes.tasks
stats.value = {
agents: {
total: agents.length,
online: agents.filter(a => a.status === 'online' || a.status === 'busy').length
},
tasks: {
total: tasks.length,
done: tasks.filter(t => t.status === 'done').length,
inProgress: tasks.filter(t => t.status === 'in_progress').length,
todo: tasks.filter(t => t.status === 'todo' || t.status === 'backlog').length
},
projects: projectsRes.projects.length,
logs: { info: 0, warn: 0, error: 0 }
}
recentLogs.value = logsRes.logs
} catch (err) {
console.error('Failed to load stats:', err)
} finally {
loading.value = false
}
}
function formatTime(timestamp: string) {
return new Date(timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
onMounted(loadStats)
</script>
<template>
<div>
<div class="page-header">
<h1>Dashboard</h1>
</div>
<div v-if="loading" class="loading">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
</div>
<template v-else>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon" style="background: rgba(99, 102, 241, 0.2); color: #818cf8;">
<i class="pi pi-users"></i>
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.agents.online }}/{{ stats.agents.total }}</div>
<div class="stat-label">Agents Online</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(34, 197, 94, 0.2); color: #4ade80;">
<i class="pi pi-check-circle"></i>
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.tasks.done }}</div>
<div class="stat-label">Tasks erledigt</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(251, 191, 36, 0.2); color: #fbbf24;">
<i class="pi pi-clock"></i>
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.tasks.inProgress }}</div>
<div class="stat-label">In Bearbeitung</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: rgba(139, 92, 246, 0.2); color: #a78bfa;">
<i class="pi pi-folder"></i>
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.projects }}</div>
<div class="stat-label">Projekte</div>
</div>
</div>
</div>
<div class="dashboard-grid">
<div class="dashboard-card">
<h3>Willkommen, {{ authStore.user?.username }}! 👋</h3>
<p>Das Agent Management System ist bereit. Verwalte deine Agents, Tasks und Projekte.</p>
<div class="quick-links">
<router-link to="/agents" class="quick-link">
<i class="pi pi-users"></i> Agents
</router-link>
<router-link to="/tasks" class="quick-link">
<i class="pi pi-list-check"></i> Tasks
</router-link>
<router-link to="/projects" class="quick-link">
<i class="pi pi-folder"></i> Projekte
</router-link>
</div>
</div>
<div class="dashboard-card">
<h3>Letzte Aktivitäten</h3>
<div v-if="recentLogs.length === 0" class="no-logs">
Keine Logs in den letzten 24 Stunden
</div>
<div v-else class="recent-logs">
<div v-for="log in recentLogs" :key="log._id" class="log-item">
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
<span class="log-level" :class="log.level">{{ log.level }}</span>
<span class="log-msg">{{ log.message }}</span>
</div>
</div>
<router-link to="/logs" class="view-all">Alle Logs anzeigen </router-link>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.loading {
text-align: center;
padding: 4rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
display: flex;
align-items: center;
gap: 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.25rem;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
}
.stat-label {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
}
.dashboard-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
}
.dashboard-card h3 {
margin: 0 0 1rem;
color: #fff;
font-size: 1.1rem;
}
.dashboard-card p {
color: rgba(255, 255, 255, 0.6);
margin: 0 0 1.5rem;
line-height: 1.6;
}
.quick-links {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.quick-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(99, 102, 241, 0.2);
border-radius: 8px;
color: #818cf8;
text-decoration: none;
font-size: 0.875rem;
transition: background 0.2s;
}
.quick-link:hover {
background: rgba(99, 102, 241, 0.3);
}
.no-logs {
color: rgba(255, 255, 255, 0.4);
font-style: italic;
padding: 1rem 0;
}
.recent-logs {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.log-item {
display: flex;
gap: 0.75rem;
font-size: 0.8rem;
align-items: center;
}
.log-time {
color: rgba(255, 255, 255, 0.4);
font-family: monospace;
}
.log-level {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 600;
}
.log-level.info { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
.log-level.warn { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.log-level.error { background: rgba(239, 68, 68, 0.2); color: #f87171; }
.log-level.debug { background: rgba(107, 114, 128, 0.2); color: #9ca3af; }
.log-msg {
color: rgba(255, 255, 255, 0.8);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.view-all {
display: block;
color: #818cf8;
text-decoration: none;
font-size: 0.875rem;
}
.view-all:hover {
text-decoration: underline;
}
</style>

1186
src/views/GitLabView.vue Normal file

File diff suppressed because it is too large Load Diff

561
src/views/LabelsView.vue Normal file
View File

@@ -0,0 +1,561 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import Toast from 'primevue/toast'
import Dialog from 'primevue/dialog'
import { api } from '../api'
interface Label {
_id: string
name: string
color: string
}
interface UsageInfo {
labelId: string
usage: {
assignments: number
tasks: number
total: number
}
}
const toast = useToast()
const labels = ref<Label[]>([])
const loading = ref(true)
// Create/Edit state
const createDialogVisible = ref(false)
const newLabelName = ref('')
const newLabelColor = ref('#6366f1')
const createLoading = ref(false)
const editingLabel = ref<Label | null>(null)
// Delete state
const deleteDialogVisible = ref(false)
const labelToDelete = ref<Label | null>(null)
const usageInfo = ref<UsageInfo | null>(null)
const usageLoading = ref(false)
const deleteAction = ref<'remove' | 'replace'>('remove')
const replacementLabelId = ref<string | null>(null)
const deleteLoading = ref(false)
const availableReplacements = computed(() =>
labels.value.filter((l) => l._id !== labelToDelete.value?._id)
)
async function loadLabels() {
try {
const data = await api.get<{ labels: Label[] }>('/labels')
labels.value = data.labels || []
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Labels konnten nicht geladen werden', life: 3000 })
} finally {
loading.value = false
}
}
function openCreateDialog() {
editingLabel.value = null
newLabelName.value = ''
newLabelColor.value = '#6366f1'
createDialogVisible.value = true
}
function openEditDialog(label: Label) {
editingLabel.value = label
newLabelName.value = label.name
newLabelColor.value = label.color
createDialogVisible.value = true
}
async function saveLabel() {
if (!newLabelName.value.trim()) return
createLoading.value = true
try {
if (editingLabel.value) {
await api.put(`/labels/${editingLabel.value._id}`, { name: newLabelName.value.trim(), color: newLabelColor.value })
toast.add({ severity: 'success', summary: 'Aktualisiert', detail: `Label "${newLabelName.value}" aktualisiert`, life: 3000 })
} else {
await api.post('/labels', { name: newLabelName.value.trim(), color: newLabelColor.value })
toast.add({ severity: 'success', summary: 'Erstellt', detail: `Label "${newLabelName.value}" erstellt`, life: 3000 })
}
createDialogVisible.value = false
await loadLabels()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Label konnte nicht gespeichert werden', life: 3000 })
} finally {
createLoading.value = false
}
}
async function openDeleteDialog(label: Label) {
labelToDelete.value = label
usageInfo.value = null
deleteAction.value = 'remove'
replacementLabelId.value = null
usageLoading.value = true
deleteDialogVisible.value = true
try {
usageInfo.value = await api.get<UsageInfo>(`/labels/${label._id}/usage`)
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Verwendung konnte nicht geprüft werden', life: 3000 })
} finally {
usageLoading.value = false
}
}
async function confirmDelete() {
if (!labelToDelete.value) return
const id = labelToDelete.value._id
deleteLoading.value = true
try {
const query = deleteAction.value === 'replace' && replacementLabelId.value
? `?replacementId=${replacementLabelId.value}`
: ''
await api.delete(`/labels/${id}${query}`)
toast.add({ severity: 'success', summary: 'Gelöscht', detail: `Label "${labelToDelete.value.name}" wurde gelöscht`, life: 3000 })
deleteDialogVisible.value = false
await loadLabels()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Label konnte nicht gelöscht werden', life: 3000 })
} finally {
deleteLoading.value = false
}
}
const canConfirmDelete = computed(() => {
if (deleteAction.value === 'replace') return !!replacementLabelId.value
return true
})
onMounted(loadLabels)
</script>
<template>
<div class="labels-view">
<Toast />
<div class="page-header">
<h2><i class="pi pi-tag"></i> Labels</h2>
<button class="dialog-btn primary" @click="openCreateDialog">
<i class="pi pi-plus"></i> Neues Label
</button>
</div>
<div v-if="loading" class="loading-state">
<i class="pi pi-spin pi-spinner"></i> Lade Labels...
</div>
<div v-else class="labels-grid">
<div v-for="label in labels" :key="label._id" class="label-card">
<span class="label-badge" :style="{ backgroundColor: label.color }">
{{ label.name }}
</span>
<div class="label-actions">
<button class="icon-btn-edit" @click="openEditDialog(label)" title="Bearbeiten">
<i class="pi pi-pencil"></i>
</button>
<button class="icon-btn-delete" @click="openDeleteDialog(label)" title="Löschen">
<i class="pi pi-trash"></i>
</button>
</div>
</div>
<div v-if="labels.length === 0" class="empty-state">
Keine Labels vorhanden.
</div>
</div>
<!-- Create Dialog -->
<Dialog
v-model:visible="createDialogVisible"
:header="editingLabel ? 'Label bearbeiten' : 'Neues Label'"
:modal="true"
:style="{ width: '24rem' }"
>
<div class="create-form">
<div class="form-group">
<label>Name</label>
<input
v-model="newLabelName"
class="form-input"
placeholder="z.B. Feature, Bug, Dringend..."
@keyup.enter="createLabel"
/>
</div>
<div class="form-group">
<label>Farbe</label>
<div class="color-row">
<input type="color" v-model="newLabelColor" class="color-picker" />
<span class="label-badge" :style="{ backgroundColor: newLabelColor }">
{{ newLabelName || 'Vorschau' }}
</span>
</div>
</div>
</div>
<template #footer>
<button class="dialog-btn" @click="createDialogVisible = false">Abbrechen</button>
<button
class="dialog-btn primary"
:disabled="!newLabelName.trim() || createLoading"
@click="saveLabel"
>
<i v-if="createLoading" class="pi pi-spin pi-spinner"></i>
{{ editingLabel ? 'Speichern' : 'Erstellen' }}
</button>
</template>
</Dialog>
<!-- Delete Dialog -->
<Dialog
v-model:visible="deleteDialogVisible"
header="Label löschen"
:modal="true"
:style="{ width: '28rem' }"
>
<div v-if="usageLoading" class="loading-state" style="padding: 1rem 0">
<i class="pi pi-spin pi-spinner"></i> Prüfe Verwendung...
</div>
<div v-else-if="usageInfo" class="delete-content">
<p class="delete-question">
Label <strong>{{ labelToDelete?.name }}"</strong> löschen?
</p>
<div v-if="usageInfo.usage.total > 0" class="usage-section">
<div class="usage-warning">
<p class="usage-title">⚠️ Label wird verwendet</p>
<p class="usage-detail">
{{ usageInfo.usage.tasks }} Task(s), {{ usageInfo.usage.assignments }} Zuweisung(en)
</p>
</div>
<div class="action-options">
<label class="action-option" :class="{ active: deleteAction === 'remove' }">
<input type="radio" v-model="deleteAction" value="remove" />
Label überall entfernen
</label>
<label class="action-option" :class="{ active: deleteAction === 'replace' }">
<input type="radio" v-model="deleteAction" value="replace" />
Durch anderes Label ersetzen
</label>
<select
v-if="deleteAction === 'replace'"
v-model="replacementLabelId"
class="replacement-select"
>
<option :value="null" disabled>Label auswählen...</option>
<option v-for="l in availableReplacements" :key="l._id" :value="l._id">
{{ l.name }}
</option>
</select>
</div>
</div>
<div v-else class="no-usage">
Dieses Label wird nirgends verwendet.
</div>
</div>
<template #footer>
<button class="dialog-btn" @click="deleteDialogVisible = false">Abbrechen</button>
<button
class="dialog-btn danger"
:disabled="!canConfirmDelete || usageLoading || deleteLoading"
@click="confirmDelete"
>
<i v-if="deleteLoading" class="pi pi-spin pi-spinner"></i>
<i v-else class="pi pi-trash"></i>
Löschen
</button>
</template>
</Dialog>
</div>
</template>
<style scoped>
.labels-view {
padding: 1.5rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h2 {
margin: 0;
color: #e0e0e0;
font-size: 1.4rem;
}
.page-header h2 i {
margin-right: 0.5rem;
}
.loading-state {
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.5);
}
.labels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.label-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
transition: all 0.15s;
}
.label-card:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
.label-badge {
display: inline-block;
padding: 0.3rem 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
color: #fff;
}
.label-actions {
display: flex;
gap: 0.25rem;
}
.icon-btn-edit,
.icon-btn-delete {
background: none;
border: none;
color: rgba(255, 255, 255, 0.3);
cursor: pointer;
padding: 0.35rem;
border-radius: 6px;
transition: all 0.15s;
font-size: 0.85rem;
}
.icon-btn-edit:hover {
color: #a5b4fc;
background: rgba(99, 102, 241, 0.15);
}
.icon-btn-delete:hover {
color: #f87171;
background: rgba(248, 113, 113, 0.15);
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.4);
}
/* Delete Dialog Content */
.delete-content {
padding: 0.5rem 0;
}
.delete-question {
color: #e0e0e0;
margin-bottom: 1rem;
}
.delete-question strong {
color: #fff;
}
.usage-warning {
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.25);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.usage-title {
color: #facc15;
font-size: 0.85rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
}
.usage-detail {
color: rgba(250, 204, 21, 0.7);
font-size: 0.8rem;
margin: 0;
}
.action-options {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.action-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.action-option:hover {
border-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
.action-option.active {
border-color: rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.1);
color: #a5b4fc;
}
.action-option input[type="radio"] {
accent-color: #6366f1;
}
.replacement-select {
width: 100%;
margin-top: 0.5rem;
padding: 0.5rem 0.65rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #fff;
font-size: 0.85rem;
outline: none;
}
.replacement-select:focus {
border-color: #6366f1;
}
.replacement-select option {
background: #1a1a2e;
color: #fff;
}
.no-usage {
color: rgba(255, 255, 255, 0.4);
font-size: 0.85rem;
}
.dialog-btn {
padding: 0.45rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.8);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.dialog-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.dialog-btn.danger {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.dialog-btn.danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.35);
color: #fff;
}
.dialog-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.usage-section {
margin-bottom: 0.5rem;
}
/* Create Form */
.create-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.form-group label {
color: rgba(255, 255, 255, 0.7);
font-size: 0.85rem;
font-weight: 500;
}
.form-input {
padding: 0.5rem 0.65rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
font-family: inherit;
outline: none;
}
.form-input:focus {
border-color: #6366f1;
}
.form-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.color-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.color-picker {
width: 40px;
height: 36px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
background: transparent;
cursor: pointer;
padding: 2px;
}
</style>

97
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const email = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const feVersion = import.meta.env.VITE_APP_VERSION || 'dev'
const beVersion = ref('...')
onMounted(async () => {
try {
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
const res = await fetch(`${apiBase}/version`)
const data = await res.json()
beVersion.value = data.version || 'unknown'
} catch {
beVersion.value = '?'
}
})
async function handleLogin() {
if (!email.value || !password.value) {
error.value = 'Bitte alle Felder ausfüllen'
return
}
loading.value = true
error.value = ''
try {
await authStore.login(email.value, password.value)
router.push('/')
} catch (err: any) {
error.value = err.message || 'Login fehlgeschlagen'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>🤖 Agent Management</h1>
<p>Melde dich an, um fortzufahren</p>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<form @submit.prevent="handleLogin">
<div class="form-field">
<label for="email">E-Mail</label>
<input
id="email"
v-model="email"
type="email"
placeholder="deine@email.de"
autocomplete="email"
/>
</div>
<div class="form-field">
<label for="password">Passwort</label>
<input
id="password"
v-model="password"
type="password"
placeholder="••••••••"
autocomplete="current-password"
/>
</div>
<button type="submit" class="submit-btn" :disabled="loading">
{{ loading ? 'Wird angemeldet...' : 'Anmelden' }}
</button>
</form>
<div class="switch-mode">
Noch kein Konto? <router-link to="/register">Registrieren</router-link>
</div>
<div class="version-info">
FE {{ feVersion }} | BE {{ beVersion }}
</div>
</div>
</div>
</template>

532
src/views/LogsView.vue Normal file
View File

@@ -0,0 +1,532 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
interface LogEntry {
_id: string
agentId: string
agentName: string
level: 'info' | 'warn' | 'error' | 'debug'
message: string
metadata?: Record<string, unknown>
timestamp: string
}
interface Agent {
_id: string
name: string
emoji: string
}
const toast = useToast()
const logs = ref<LogEntry[]>([])
const agents = ref<Agent[]>([])
const loading = ref(true)
const autoRefresh = ref(true)
let refreshInterval: number | null = null
// Filters
const filterAgent = ref('')
const filterLevel = ref('')
const filterHours = ref('24')
const searchQuery = ref('')
// Stats
interface LogStats {
overall: Record<string, number>
byAgent: Array<{ _id: string; agentName: string; total: number; levels: Array<{ level: string; count: number }> }>
}
const stats = ref<LogStats | null>(null)
// Detail
const selectedLog = ref<LogEntry | null>(null)
async function loadLogs() {
try {
const params = new URLSearchParams()
if (filterAgent.value) params.set('agentId', filterAgent.value)
if (filterLevel.value) params.set('level', filterLevel.value)
if (filterHours.value) params.set('hours', filterHours.value)
params.set('limit', '200')
const [logsRes, agentsRes, statsRes] = await Promise.all([
api.get<{ logs: LogEntry[] }>(`/logs?${params}`),
agents.value.length ? Promise.resolve({ agents: agents.value }) : api.get<{ agents: Agent[] }>('/agents'),
api.get<LogStats>(`/logs/stats?hours=${filterHours.value}`)
])
logs.value = logsRes.logs
if (agentsRes.agents) agents.value = agentsRes.agents
stats.value = statsRes
} catch (err) {
console.error('Failed to load logs:', err)
} finally {
loading.value = false
}
}
const filteredLogs = computed(() => {
if (!searchQuery.value) return logs.value
const q = searchQuery.value.toLowerCase()
return logs.value.filter(l =>
l.message.toLowerCase().includes(q) ||
l.agentName.toLowerCase().includes(q)
)
})
function getAgentEmoji(agentId: string) {
const agent = agents.value.find(a => a._id === agentId)
return agent?.emoji || '🤖'
}
function getLevelColor(level: string) {
switch (level) {
case 'error': return '#ef4444'
case 'warn': return '#f59e0b'
case 'info': return '#3b82f6'
case 'debug': return '#6b7280'
default: return '#6b7280'
}
}
function formatTime(timestamp: string) {
const date = new Date(timestamp)
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatDate(timestamp: string) {
const date = new Date(timestamp)
const today = new Date()
if (date.toDateString() === today.toDateString()) {
return 'Heute'
}
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) {
return 'Gestern'
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value
if (autoRefresh.value) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
function startAutoRefresh() {
if (refreshInterval) clearInterval(refreshInterval)
refreshInterval = setInterval(loadLogs, 10000) as unknown as number
}
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
}
onMounted(() => {
loadLogs()
if (autoRefresh.value) startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<template>
<div>
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center;">
<h1>Logs</h1>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button
@click="toggleAutoRefresh"
class="refresh-btn"
:class="{ active: autoRefresh }"
>
<i class="pi" :class="autoRefresh ? 'pi-pause' : 'pi-play'"></i>
{{ autoRefresh ? 'Auto' : 'Pausiert' }}
</button>
<button @click="loadLogs" class="refresh-btn">
<i class="pi pi-refresh" :class="{ 'pi-spin': loading }"></i>
</button>
</div>
</div>
<!-- Stats -->
<div v-if="stats" class="log-stats">
<div class="stat-card">
<span class="stat-number">{{ (stats.overall.info || 0) + (stats.overall.warn || 0) + (stats.overall.error || 0) + (stats.overall.debug || 0) }}</span>
<span class="stat-label">Gesamt</span>
</div>
<div class="stat-card error-card">
<span class="stat-number">{{ stats.overall.error || 0 }}</span>
<span class="stat-label">Errors</span>
</div>
<div class="stat-card warn-card">
<span class="stat-number">{{ stats.overall.warn || 0 }}</span>
<span class="stat-label">Warnings</span>
</div>
<div class="stat-card info-card">
<span class="stat-number">{{ stats.overall.info || 0 }}</span>
<span class="stat-label">Info</span>
</div>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="searchQuery"
type="search"
placeholder="🔍 Suche in Logs..."
class="search-input"
/>
<select v-model="filterAgent" @change="loadLogs">
<option value="">Alle Agents</option>
<option v-for="agent in agents" :key="agent._id" :value="agent._id">
{{ agent.emoji }} {{ agent.name }}
</option>
</select>
<select v-model="filterLevel" @change="loadLogs">
<option value="">Alle Level</option>
<option value="error">🔴 Error</option>
<option value="warn">🟡 Warning</option>
<option value="info">🔵 Info</option>
<option value="debug"> Debug</option>
</select>
<select v-model="filterHours" @change="loadLogs">
<option value="1">Letzte Stunde</option>
<option value="6">Letzte 6 Stunden</option>
<option value="24">Letzte 24 Stunden</option>
<option value="72">Letzte 3 Tage</option>
<option value="168">Letzte Woche</option>
</select>
</div>
<!-- Logs Table -->
<div class="logs-container">
<div v-if="loading && logs.length === 0" class="loading">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
</div>
<div v-else-if="logs.length === 0" class="empty">
<i class="pi pi-inbox" style="font-size: 3rem; color: rgba(255,255,255,0.2);"></i>
<p>Keine Logs gefunden</p>
</div>
<div v-else class="logs-list">
<div class="log-count">{{ filteredLogs.length }} Einträge</div>
<div
v-for="log in filteredLogs"
:key="log._id"
class="log-entry"
:class="[log.level, { selected: selectedLog?._id === log._id }]"
@click="selectedLog = selectedLog?._id === log._id ? null : log"
>
<div class="log-time">
<span class="log-date">{{ formatDate(log.timestamp) }}</span>
<span class="log-timestamp">{{ formatTime(log.timestamp) }}</span>
</div>
<div class="log-level" :style="{ color: getLevelColor(log.level) }">
{{ log.level.toUpperCase() }}
</div>
<div class="log-agent">
{{ getAgentEmoji(log.agentId) }} {{ log.agentName }}
</div>
<div class="log-message">{{ log.message }}</div>
</div>
</div>
<!-- Detail Panel -->
<div v-if="selectedLog" class="log-detail">
<div class="detail-header">
<h4>Log Detail</h4>
<button class="close-btn" @click="selectedLog = null"><i class="pi pi-times"></i></button>
</div>
<div class="detail-row">
<span class="detail-label">Zeitpunkt</span>
<span>{{ new Date(selectedLog.timestamp).toLocaleString('de-DE') }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Level</span>
<span :style="{ color: getLevelColor(selectedLog.level) }">{{ selectedLog.level.toUpperCase() }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Agent</span>
<span>{{ getAgentEmoji(selectedLog.agentId) }} {{ selectedLog.agentName }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Nachricht</span>
<pre class="detail-message">{{ selectedLog.message }}</pre>
</div>
<div v-if="selectedLog.metadata && Object.keys(selectedLog.metadata).length" class="detail-row">
<span class="detail-label">Metadata</span>
<pre class="detail-metadata">{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre>
</div>
<div class="detail-row">
<span class="detail-label">ID</span>
<span class="mono">{{ selectedLog._id }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.log-stats {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.stat-card {
flex: 1;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 0.75rem;
text-align: center;
}
.stat-card.error-card { border-color: rgba(239, 68, 68, 0.3); }
.stat-card.warn-card { border-color: rgba(245, 158, 11, 0.3); }
.stat-card.info-card { border-color: rgba(59, 130, 246, 0.3); }
.stat-number {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: #fff;
}
.stat-card.error-card .stat-number { color: #f87171; }
.stat-card.warn-card .stat-number { color: #fbbf24; }
.stat-card.info-card .stat-number { color: #60a5fa; }
.stat-label {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.search-input {
flex: 1;
min-width: 200px;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
font-size: 0.875rem;
}
.search-input::placeholder { color: rgba(255, 255, 255, 0.4); }
.search-input:focus { outline: none; border-color: #6366f1; }
.log-count {
padding: 0.4rem 1rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.log-entry.selected {
background: rgba(99, 102, 241, 0.15) !important;
border-left: 2px solid #6366f1;
}
.log-entry { cursor: pointer; }
.log-detail {
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 1rem;
background: rgba(0, 0, 0, 0.3);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.detail-header h4 { margin: 0; color: rgba(255, 255, 255, 0.8); font-size: 0.9rem; }
.close-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 0.25rem;
}
.close-btn:hover { color: #fff; }
.detail-row {
margin-bottom: 0.5rem;
}
.detail-label {
display: block;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.15rem;
}
.detail-message {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.9);
font-family: inherit;
}
.detail-metadata {
margin: 0;
background: rgba(0, 0, 0, 0.3);
padding: 0.5rem;
border-radius: 6px;
font-size: 0.75rem;
color: #a78bfa;
font-family: monospace;
overflow-x: auto;
}
.mono { font-family: monospace; font-size: 0.75rem; color: rgba(255, 255, 255, 0.5); }
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.filters select {
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
font-size: 0.875rem;
cursor: pointer;
}
.refresh-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: all 0.2s;
}
.refresh-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.refresh-btn.active {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.logs-container {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
overflow: hidden;
max-height: calc(100vh - 280px);
overflow-y: auto;
}
.loading, .empty {
text-align: center;
padding: 4rem 2rem;
color: rgba(255, 255, 255, 0.5);
}
.empty p {
margin: 1rem 0 0;
}
.logs-list {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.8rem;
}
.log-entry {
display: grid;
grid-template-columns: 100px 60px 140px 1fr;
gap: 1rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
align-items: center;
}
.log-entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.log-entry.error {
background: rgba(239, 68, 68, 0.05);
}
.log-entry.warn {
background: rgba(251, 191, 36, 0.05);
}
.log-time {
display: flex;
flex-direction: column;
color: rgba(255, 255, 255, 0.4);
font-size: 0.75rem;
}
.log-date {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.3);
}
.log-level {
font-weight: 600;
font-size: 0.7rem;
}
.log-agent {
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.log-message {
color: rgba(255, 255, 255, 0.9);
word-break: break-word;
}
@media (max-width: 768px) {
.log-entry {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.log-time {
flex-direction: row;
gap: 0.5rem;
}
}
</style>

671
src/views/ProjectsView.vue Normal file
View File

@@ -0,0 +1,671 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
interface Task {
_id: string
title: string
status: string
priority: string
project?: string
}
interface GitLabProjectRef {
projectId: number
path: string
url: string
name: string
}
interface Project {
_id: string
name: string
description?: string
color: string
rules?: string
gitlabProjects?: GitLabProjectRef[]
gitlabProjectId?: number
gitlabUrl?: string
gitlabPath?: string
}
interface GitLabProject {
id: number
name: string
fullName: string
path: string
description: string | null
webUrl: string
}
const authStore = useAuthStore()
const toast = useToast()
const projects = ref<Project[]>([])
const tasks = ref<Task[]>([])
const gitlabProjects = ref<GitLabProject[]>([])
const loading = ref(true)
const showEditModal = ref(false)
const showCreateModal = ref(false)
const editingProject = ref<Project | null>(null)
const newProject = ref({
name: '',
description: '',
color: '#6366f1',
rules: '',
gitlabProjects: [] as GitLabProjectRef[]
})
async function loadData() {
loading.value = true
try {
const [projectsRes, tasksRes] = await Promise.all([
api.get<{ projects: Project[] }>('/tasks/projects/list'),
api.get<{ tasks: Task[] }>('/tasks')
])
projects.value = projectsRes.projects
tasks.value = tasksRes.tasks
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Daten konnten nicht geladen werden', life: 5000 })
} finally {
loading.value = false
}
}
async function loadGitlabProjects() {
try {
gitlabProjects.value = await api.get('/gitlab/projects')
} catch (err) {
console.error('Failed to load GitLab projects:', err)
}
}
const selectedGitlabIds = ref<number[]>([])
function initGitlabSelection() {
if (!editingProject.value) return
if (editingProject.value.gitlabProjects?.length) {
selectedGitlabIds.value = editingProject.value.gitlabProjects.map(p => p.projectId)
} else if (editingProject.value.gitlabProjectId) {
selectedGitlabIds.value = [editingProject.value.gitlabProjectId]
} else {
selectedGitlabIds.value = []
}
}
function toggleGitlabProject(gitlabProject: GitLabProject) {
const idx = selectedGitlabIds.value.indexOf(gitlabProject.id)
if (idx >= 0) {
selectedGitlabIds.value.splice(idx, 1)
} else {
selectedGitlabIds.value.push(gitlabProject.id)
}
updateGitlabProjects()
}
function updateGitlabProjects() {
const glProjects = selectedGitlabIds.value.map(id => {
const gp = gitlabProjects.value.find(p => p.id === id)
return gp ? {
projectId: gp.id,
path: gp.path,
url: gp.webUrl,
name: gp.fullName
} : null
}).filter(Boolean) as GitLabProjectRef[]
if (editingProject.value) {
editingProject.value.gitlabProjects = glProjects
} else {
newProject.value.gitlabProjects = glProjects
}
}
function isGitlabSelected(id: number): boolean {
return selectedGitlabIds.value.includes(id)
}
function getProjectTasks(projectId: string) {
return tasks.value.filter(t => t.project === projectId)
}
function getTaskStats(projectId: string) {
const projectTasks = getProjectTasks(projectId)
return {
total: projectTasks.length,
done: projectTasks.filter(t => t.status === 'done').length,
inProgress: projectTasks.filter(t => t.status === 'in_progress').length,
todo: projectTasks.filter(t => t.status === 'todo' || t.status === 'backlog').length
}
}
function openCreateModal() {
newProject.value = {
name: '',
description: '',
color: '#6366f1',
rules: '',
gitlabProjects: []
}
selectedGitlabIds.value = []
showCreateModal.value = true
}
function editProject(project: Project) {
editingProject.value = { ...project }
initGitlabSelection()
showEditModal.value = true
}
async function createProject() {
if (!newProject.value.name.trim()) {
toast.add({ severity: 'warn', summary: 'Fehler', detail: 'Name ist erforderlich', life: 3000 })
return
}
try {
await api.post('/tasks/projects', {
name: newProject.value.name,
description: newProject.value.description,
color: newProject.value.color,
rules: newProject.value.rules,
gitlabProjects: newProject.value.gitlabProjects
})
toast.add({ severity: 'success', summary: 'Erstellt', detail: 'Projekt wurde angelegt', life: 3000 })
showCreateModal.value = false
await loadData()
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
}
}
async function saveProject() {
if (!editingProject.value) return
try {
await api.put(`/tasks/projects/${editingProject.value._id}`, {
name: editingProject.value.name,
description: editingProject.value.description,
color: editingProject.value.color,
rules: editingProject.value.rules,
gitlabProjects: editingProject.value.gitlabProjects,
gitlabProjectId: editingProject.value.gitlabProjects?.[0]?.projectId,
gitlabUrl: editingProject.value.gitlabProjects?.[0]?.url,
gitlabPath: editingProject.value.gitlabProjects?.[0]?.path
})
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Projekt aktualisiert', life: 3000 })
showEditModal.value = false
await loadData()
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
}
}
async function deleteProject(id: string, name: string) {
if (!confirm(`Projekt "${name}" wirklich löschen?`)) return
try {
await api.delete(`/tasks/projects/${id}`)
toast.add({ severity: 'success', summary: 'Gelöscht', detail: 'Projekt wurde entfernt', life: 3000 })
await loadData()
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
}
}
onMounted(() => {
loadData()
loadGitlabProjects()
})
</script>
<template>
<div>
<div class="page-header">
<h1>Projekte</h1>
<button v-if="authStore.user?.role === 'admin'" @click="openCreateModal" class="create-btn">
<i class="pi pi-plus"></i> Neues Projekt
</button>
</div>
<div v-if="loading" class="loading">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
</div>
<div v-else class="projects-grid">
<div v-for="project in projects" :key="project._id" class="project-card">
<div class="project-header" :style="{ borderColor: project.color }">
<div class="project-color" :style="{ background: project.color }"></div>
<div class="project-info">
<h3>{{ project.name }}</h3>
<p v-if="project.description">{{ project.description }}</p>
</div>
<button
v-if="authStore.user?.role === 'admin'"
@click="editProject(project)"
class="edit-btn"
title="Bearbeiten"
>
<i class="pi pi-pencil"></i>
</button>
</div>
<div class="project-stats">
<div class="stat">
<span class="stat-value">{{ getTaskStats(project._id).total }}</span>
<span class="stat-label">Tasks</span>
</div>
<div class="stat done">
<span class="stat-value">{{ getTaskStats(project._id).done }}</span>
<span class="stat-label">Erledigt</span>
</div>
<div class="stat progress">
<span class="stat-value">{{ getTaskStats(project._id).inProgress }}</span>
<span class="stat-label">In Arbeit</span>
</div>
<div class="stat todo">
<span class="stat-value">{{ getTaskStats(project._id).todo }}</span>
<span class="stat-label">Offen</span>
</div>
</div>
<div v-if="project.gitlabProjects?.length || project.gitlabPath" class="project-gitlab">
<h4><i class="pi pi-github"></i> GitLab</h4>
<template v-if="project.gitlabProjects?.length">
<div v-for="gp in project.gitlabProjects" :key="gp.projectId" class="gitlab-project-item">
<a :href="gp.url" target="_blank" class="gitlab-link">
<i class="pi pi-external-link"></i>
{{ gp.path }}
</a>
<span class="gitlab-id">ID: {{ gp.projectId }}</span>
</div>
</template>
<template v-else-if="project.gitlabPath || project.gitlabUrl">
<a v-if="project.gitlabUrl" :href="project.gitlabUrl" target="_blank" class="gitlab-link">
<i class="pi pi-external-link"></i>
{{ project.gitlabPath || project.gitlabUrl }}
</a>
</template>
</div>
<div v-if="project.rules" class="project-rules">
<h4><i class="pi pi-book"></i> Regeln</h4>
<p>{{ project.rules }}</p>
</div>
<div class="project-tasks">
<h4>Aktuelle Tasks</h4>
<div v-if="getProjectTasks(project._id).length === 0" class="no-tasks">
Keine Tasks
</div>
<div v-else class="task-list">
<div
v-for="task in getProjectTasks(project._id).slice(0, 5)"
:key="task._id"
class="task-item"
>
<span class="task-status" :class="task.status"></span>
<span class="task-title">{{ task.title }}</span>
<span class="task-priority" :class="task.priority">{{ task.priority }}</span>
</div>
<div v-if="getProjectTasks(project._id).length > 5" class="more-tasks">
+{{ getProjectTasks(project._id).length - 5 }} weitere
</div>
</div>
</div>
<router-link :to="`/tasks?project=${project._id}`" class="view-all-btn">
Alle Tasks anzeigen
</router-link>
</div>
<div v-if="projects.length === 0" class="empty-state">
<i class="pi pi-folder-open"></i>
<p>Noch keine Projekte vorhanden</p>
<button v-if="authStore.user?.role === 'admin'" @click="openCreateModal" class="create-btn">
<i class="pi pi-plus"></i> Erstes Projekt anlegen
</button>
</div>
</div>
<!-- Create Project Modal -->
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
<div class="modal">
<h2>Neues Projekt</h2>
<div class="modal-body">
<div class="form-field">
<label>Name *</label>
<input v-model="newProject.name" type="text" placeholder="Projektname" />
</div>
<div class="form-field">
<label>Beschreibung</label>
<input v-model="newProject.description" type="text" placeholder="Kurze Beschreibung" />
</div>
<div class="form-field">
<label>Farbe</label>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button
v-for="color in ['#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f59e0b', '#22c55e', '#06b6d4', '#3b82f6']"
:key="color"
@click="newProject.color = color"
class="color-btn"
:class="{ active: newProject.color === color }"
:style="{ background: color }"
></button>
</div>
</div>
<div class="form-field">
<label>Regeln / Hinweise</label>
<textarea v-model="newProject.rules" rows="3" placeholder="z.B. Code-Review erforderlich..."></textarea>
</div>
<div v-if="gitlabProjects.length > 0" class="gitlab-section">
<h4><i class="pi pi-github"></i> GitLab Projekte</h4>
<div class="form-field">
<label>Verknüpfte Projekte (optional)</label>
<div class="gitlab-multi-select">
<div
v-for="gp in gitlabProjects"
:key="gp.id"
class="gitlab-option"
:class="{ selected: isGitlabSelected(gp.id) }"
@click="toggleGitlabProject(gp)"
>
<i :class="isGitlabSelected(gp.id) ? 'pi pi-check-square' : 'pi pi-stop'" class="checkbox-icon"></i>
<span class="gitlab-name">{{ gp.fullName }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="modal-actions">
<button @click="showCreateModal = false" class="cancel-btn">Abbrechen</button>
<button @click="createProject" class="submit-btn">Projekt erstellen</button>
</div>
</div>
</div>
<!-- Edit Project Modal -->
<div v-if="showEditModal && editingProject" class="modal-overlay" @click.self="showEditModal = false">
<div class="modal">
<h2>Projekt bearbeiten</h2>
<div class="modal-body">
<div class="form-field">
<label>Name</label>
<input v-model="editingProject.name" type="text" />
</div>
<div class="form-field">
<label>Beschreibung</label>
<input v-model="editingProject.description" type="text" />
</div>
<div class="form-field">
<label>Farbe</label>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button
v-for="color in ['#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f59e0b', '#22c55e', '#06b6d4', '#3b82f6']"
:key="color"
@click="editingProject.color = color"
class="color-btn"
:class="{ active: editingProject.color === color }"
:style="{ background: color }"
></button>
</div>
</div>
<div class="form-field">
<label>Regeln / Hinweise</label>
<textarea v-model="editingProject.rules" rows="3"></textarea>
</div>
<div v-if="gitlabProjects.length > 0" class="gitlab-section">
<h4><i class="pi pi-github"></i> GitLab Projekte</h4>
<div class="form-field">
<label>Verknüpfte Projekte</label>
<div class="gitlab-multi-select">
<div
v-for="gp in gitlabProjects"
:key="gp.id"
class="gitlab-option"
:class="{ selected: isGitlabSelected(gp.id) }"
@click="toggleGitlabProject(gp)"
>
<i :class="isGitlabSelected(gp.id) ? 'pi pi-check-square' : 'pi pi-stop'" class="checkbox-icon"></i>
<span class="gitlab-name">{{ gp.fullName }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="modal-actions">
<button @click="deleteProject(editingProject._id, editingProject.name)" class="delete-btn">
<i class="pi pi-trash"></i> Löschen
</button>
<div style="flex: 1;"></div>
<button @click="showEditModal = false" class="cancel-btn">Abbrechen</button>
<button @click="saveProject" class="submit-btn">Speichern</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1 { margin: 0; }
.create-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: none;
border-radius: 10px;
color: #fff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.create-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.project-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
overflow: hidden;
}
.project-header {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.25rem;
border-bottom: 3px solid;
}
.project-color {
width: 48px;
height: 48px;
border-radius: 12px;
flex-shrink: 0;
}
.project-info { flex: 1; }
.project-info h3 { margin: 0; color: #fff; font-size: 1.25rem; }
.project-info p { margin: 0.25rem 0 0; color: rgba(255,255,255,0.6); font-size: 0.875rem; }
.edit-btn {
padding: 0.5rem;
background: transparent;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
color: rgba(255,255,255,0.6);
cursor: pointer;
}
.edit-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
.project-stats {
display: flex;
padding: 1rem 1.25rem;
gap: 1rem;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.stat { text-align: center; flex: 1; }
.stat-value { display: block; font-size: 1.5rem; font-weight: 700; color: #fff; }
.stat-label { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.stat.done .stat-value { color: #4ade80; }
.stat.progress .stat-value { color: #fbbf24; }
.stat.todo .stat-value { color: #60a5fa; }
.project-rules, .project-gitlab {
padding: 1rem 1.25rem;
background: rgba(255,255,255,0.03);
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.project-rules h4, .project-gitlab h4 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
color: rgba(255,255,255,0.7);
display: flex;
align-items: center;
gap: 0.5rem;
}
.project-gitlab h4 { color: #fb923c; }
.project-rules p { margin: 0; font-size: 0.875rem; color: rgba(255,255,255,0.6); }
.project-tasks { padding: 1rem 1.25rem; }
.project-tasks h4 { margin: 0 0 0.75rem; font-size: 0.875rem; color: rgba(255,255,255,0.7); }
.task-list { display: flex; flex-direction: column; gap: 0.5rem; }
.task-item { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; }
.task-status { width: 8px; height: 8px; border-radius: 50%; background: #64748b; }
.task-status.done { background: #4ade80; }
.task-status.in_progress { background: #fbbf24; }
.task-status.review { background: #a78bfa; }
.task-status.todo { background: #60a5fa; }
.task-status.backlog { background: #64748b; }
.task-title { flex: 1; color: rgba(255,255,255,0.8); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-priority { font-size: 0.7rem; padding: 0.125rem 0.5rem; border-radius: 4px; text-transform: uppercase; background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6); }
.task-priority.urgent { background: rgba(239,68,68,0.2); color: #f87171; }
.task-priority.high { background: rgba(251,191,36,0.2); color: #fbbf24; }
.no-tasks, .more-tasks { color: rgba(255,255,255,0.4); font-size: 0.875rem; }
.view-all-btn {
display: block;
padding: 0.75rem 1.25rem;
text-align: center;
color: #818cf8;
text-decoration: none;
font-size: 0.875rem;
border-top: 1px solid rgba(255,255,255,0.05);
}
.view-all-btn:hover { background: rgba(99,102,241,0.1); }
.loading, .empty-state { text-align: center; padding: 4rem; color: rgba(255,255,255,0.6); }
.empty-state i { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; }
.empty-state p { margin-bottom: 1.5rem; }
.gitlab-link { display: inline-flex; align-items: center; gap: 0.375rem; color: #fb923c; text-decoration: none; font-size: 0.875rem; font-family: monospace; }
.gitlab-link:hover { text-decoration: underline; }
.gitlab-id { margin-left: 0.75rem; color: rgba(255,255,255,0.4); font-size: 0.75rem; }
.gitlab-project-item { display: flex; align-items: center; padding: 0.25rem 0; }
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #1a1a2e;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
padding: 1.25rem;
width: 100%;
max-width: 500px;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.modal h2 { margin: 0 0 1rem; color: #fff; }
.modal-body { flex: 1; overflow-y: auto; padding-right: 0.5rem; }
.form-field { margin-bottom: 1rem; }
.form-field label { display: block; margin-bottom: 0.5rem; color: rgba(255,255,255,0.7); font-size: 0.875rem; }
.form-field input, .form-field textarea {
width: 100%;
padding: 0.75rem;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
color: #fff;
font-size: 1rem;
font-family: inherit;
box-sizing: border-box;
}
.color-btn { width: 32px; height: 32px; border-radius: 8px; border: 2px solid transparent; cursor: pointer; }
.color-btn.active { border-color: #fff; transform: scale(1.1); }
.modal-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
align-items: center;
padding-top: 1rem;
border-top: 1px solid rgba(255,255,255,0.1);
}
.delete-btn { padding: 0.5rem 1rem; background: transparent; border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; color: #f87171; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; }
.delete-btn:hover { background: rgba(239,68,68,0.2); }
.cancel-btn { padding: 0.75rem 1rem; background: transparent; border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; color: rgba(255,255,255,0.7); cursor: pointer; }
.submit-btn { padding: 0.75rem 1.5rem; background: linear-gradient(135deg, #6366f1, #8b5cf6); border: none; border-radius: 8px; color: #fff; font-weight: 600; cursor: pointer; }
.gitlab-section { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); }
.gitlab-section h4 { margin: 0 0 1rem; color: #fb923c; font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem; }
.gitlab-multi-select { max-height: 150px; overflow-y: auto; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; background: rgba(255,255,255,0.02); }
.gitlab-option { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.75rem; cursor: pointer; transition: background 0.2s; border-bottom: 1px solid rgba(255,255,255,0.05); }
.gitlab-option:last-child { border-bottom: none; }
.gitlab-option:hover { background: rgba(255,255,255,0.05); }
.gitlab-option.selected { background: rgba(99,102,241,0.2); }
.checkbox-icon { color: rgba(255,255,255,0.4); font-size: 1rem; }
.gitlab-option.selected .checkbox-icon { color: #6366f1; }
.gitlab-name { font-size: 0.8rem; color: rgba(255,255,255,0.9); }
</style>

143
src/views/RegisterView.vue Normal file
View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../api'
const router = useRouter()
const authStore = useAuthStore()
const email = ref('')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const error = ref('')
const loading = ref(false)
const registrationDisabled = ref(false)
onMounted(async () => {
try {
const settings = await api.get<{ allowRegistration: boolean }>('/settings')
registrationDisabled.value = !settings.allowRegistration
} catch (err) {
console.error('Failed to check registration status')
}
})
async function handleRegister() {
if (!email.value || !username.value || !password.value) {
error.value = 'Bitte alle Felder ausfüllen'
return
}
if (password.value !== confirmPassword.value) {
error.value = 'Passwörter stimmen nicht überein'
return
}
if (password.value.length < 8) {
error.value = 'Passwort muss mindestens 8 Zeichen haben'
return
}
loading.value = true
error.value = ''
try {
await authStore.register(email.value, username.value, password.value)
router.push('/')
} catch (err: any) {
error.value = err.message || 'Registrierung fehlgeschlagen'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>🤖 Agent Management</h1>
<p>Erstelle ein neues Konto</p>
</div>
<div v-if="registrationDisabled" class="info-message">
<i class="pi pi-lock" style="margin-right: 0.5rem;"></i>
Registrierung ist derzeit deaktiviert. Kontaktiere einen Administrator.
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<form v-if="!registrationDisabled" @submit.prevent="handleRegister">
<div class="form-field">
<label for="email">E-Mail</label>
<input
id="email"
v-model="email"
type="email"
placeholder="deine@email.de"
autocomplete="email"
/>
</div>
<div class="form-field">
<label for="username">Benutzername</label>
<input
id="username"
v-model="username"
type="text"
placeholder="dein_name"
autocomplete="username"
/>
</div>
<div class="form-field">
<label for="password">Passwort</label>
<input
id="password"
v-model="password"
type="password"
placeholder="••••••••"
autocomplete="new-password"
/>
</div>
<div class="form-field">
<label for="confirmPassword">Passwort bestätigen</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="••••••••"
autocomplete="new-password"
/>
</div>
<button type="submit" class="submit-btn" :disabled="loading">
{{ loading ? 'Wird registriert...' : 'Registrieren' }}
</button>
</form>
<div class="switch-mode">
Bereits registriert? <router-link to="/login">Anmelden</router-link>
</div>
</div>
</div>
</template>
<style scoped>
.info-message {
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
color: #a5b4fc;
font-size: 0.875rem;
display: flex;
align-items: center;
}
</style>

1532
src/views/SecretsView.vue Normal file

File diff suppressed because it is too large Load Diff

671
src/views/SettingsView.vue Normal file
View File

@@ -0,0 +1,671 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { useWebSocketStore } from '../stores/websocket'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
const authStore = useAuthStore()
const wsStore = useWebSocketStore()
const toast = useToast()
// WebSocket Stats
interface ServerStats {
startedAt: string
totalConnections: number
totalDisconnections: number
totalBroadcasts: number
totalMessagesSent: number
totalMessagesReceived: number
currentConnections: number
users: Array<{
userId: string
username: string
connectedAt: string
messagesSent: number
messagesReceived: number
}>
}
const serverStats = ref<ServerStats | null>(null)
let wsRefreshTimer: ReturnType<typeof setInterval> | null = null
const wsUptime = computed(() => {
const connAt = wsStore.stats.connectedAt
if (!connAt) return '—'
const diff = Date.now() - new Date(connAt).getTime()
const secs = Math.floor(diff / 1000)
if (secs < 60) return `${secs}s`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m ${secs % 60}s`
const hrs = Math.floor(mins / 60)
return `${hrs}h ${mins % 60}m`
})
const serverUptime = computed(() => {
if (!serverStats.value?.startedAt) return '—'
const diff = Date.now() - new Date(serverStats.value.startedAt).getTime()
const secs = Math.floor(diff / 1000)
if (secs < 60) return `${secs}s`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ${mins % 60}m`
const days = Math.floor(hrs / 24)
return `${days}d ${hrs % 24}h`
})
// Export/Import
const exportStats = ref<Record<string, number> | null>(null)
const exportLoading = ref(false)
const importLoading = ref(false)
async function loadExportStats() {
try {
const res = await api.get<{ stats: Record<string, number> }>('/data/stats')
exportStats.value = res.stats
} catch {
// Not admin or endpoint not available
}
}
async function exportData() {
exportLoading.value = true
try {
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
const token = localStorage.getItem('ams_token')
const res = await fetch(`${apiBase}/data/export`, {
headers: { 'Authorization': `Bearer ${token}` }
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `ams-export-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
toast.add({ severity: 'success', summary: 'Export', detail: 'Daten exportiert', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Export fehlgeschlagen', life: 5000 })
} finally {
exportLoading.value = false
}
}
async function importData(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
importLoading.value = true
try {
const text = await file.text()
const json = JSON.parse(text)
const data = json.data || json
const res = await api.post<{ results: Record<string, { inserted: number; skipped: number }> }>('/data/import', {
data,
mode: 'merge'
})
const summary = Object.entries(res.results)
.map(([col, r]) => `${col}: ${r.inserted} importiert, ${r.skipped} übersprungen`)
.join('\n')
toast.add({ severity: 'success', summary: 'Import', detail: summary, life: 8000 })
await loadExportStats()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
toast.add({ severity: 'error', summary: 'Fehler', detail: `Import fehlgeschlagen: ${message}`, life: 5000 })
} finally {
importLoading.value = false
input.value = ''
}
}
async function fetchWsStats() {
try {
const data = await api.get<ServerStats>('/ws/status')
serverStats.value = data
} catch {
serverStats.value = null
}
}
const feVersion = import.meta.env.VITE_APP_VERSION || 'dev'
const beVersion = ref('...')
// User Provider Settings
const userSettings = ref({
sttProvider: 'openai' as 'openai' | 'custom',
openaiApiKey: '',
customSttUrl: '',
telegramBotToken: '',
telegramDefaultChatId: '',
})
const savingUserSettings = ref(false)
async function loadUserSettings() {
try {
const res = await api.get<{ settings: typeof userSettings.value }>('/user-settings')
userSettings.value = res.settings
} catch {
// Keine Settings vorhanden — Defaults beibehalten
}
}
async function saveUserSettings() {
savingUserSettings.value = true
try {
await api.put('/user-settings', userSettings.value)
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Provider-Einstellungen aktualisiert', life: 3000 })
await loadUserSettings()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Speichern fehlgeschlagen'
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
} finally {
savingUserSettings.value = false
}
}
const allowRegistration = ref(false)
const loading = ref(false)
// Code Theme Settings
const codeThemes = [
{ value: 'github-dark', label: 'GitHub Dark' },
{ value: 'github-dark-dimmed', label: 'GitHub Dark Dimmed' },
{ value: 'monokai', label: 'Monokai' },
{ value: 'dracula', label: 'Dracula' },
{ value: 'atom-one-dark', label: 'Atom One Dark' },
{ value: 'vs2015', label: 'VS 2015 Dark' },
{ value: 'nord', label: 'Nord' },
{ value: 'tokyo-night-dark', label: 'Tokyo Night' },
{ value: 'agate', label: 'Agate' },
{ value: 'androidstudio', label: 'Android Studio' },
{ value: 'hybrid', label: 'Hybrid' },
{ value: 'obsidian', label: 'Obsidian' },
{ value: 'stackoverflow-dark', label: 'Stack Overflow Dark' },
{ value: 'night-owl', label: 'Night Owl' },
{ value: 'felipec', label: 'Felipec' },
]
const selectedCodeTheme = ref(localStorage.getItem('ams_code_theme') || 'github-dark')
function updateCodeTheme() {
localStorage.setItem('ams_code_theme', selectedCodeTheme.value)
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Code-Theme aktualisiert', life: 3000 })
// Dispatch event so GitLabView can react
window.dispatchEvent(new CustomEvent('code-theme-changed', { detail: selectedCodeTheme.value }))
}
async function loadSettings() {
try {
const data = await api.get<{ allowRegistration: boolean }>('/settings')
allowRegistration.value = data.allowRegistration
} catch (err) {
console.error('Failed to load settings:', err)
}
}
async function toggleRegistration() {
loading.value = true
try {
await api.put('/settings', { allowRegistration: allowRegistration.value })
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Einstellungen aktualisiert', life: 3000 })
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
// Revert on error
allowRegistration.value = !allowRegistration.value
} finally {
loading.value = false
}
}
onMounted(async () => {
loadSettings()
fetchWsStats()
loadExportStats()
loadUserSettings()
wsRefreshTimer = setInterval(fetchWsStats, 10000)
try {
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
const res = await fetch(`${apiBase}/version`)
const data = await res.json()
beVersion.value = data.version || 'unknown'
} catch {
beVersion.value = '?'
}
})
onUnmounted(() => {
if (wsRefreshTimer) clearInterval(wsRefreshTimer)
})
</script>
<template>
<div>
<div class="page-header">
<h1>Einstellungen</h1>
</div>
<div class="settings-section">
<h3>Profil</h3>
<div class="settings-card">
<div class="setting-row">
<div class="setting-label">Benutzername</div>
<div class="setting-value">{{ authStore.user?.username }}</div>
</div>
<div class="setting-row">
<div class="setting-label">E-Mail</div>
<div class="setting-value">{{ authStore.user?.email }}</div>
</div>
<div class="setting-row">
<div class="setting-label">Rolle</div>
<div class="setting-value">{{ authStore.user?.role }}</div>
</div>
</div>
</div>
<div class="settings-section">
<h3><i class="pi pi-code" style="margin-right: 0.5rem;"></i>Code Editor</h3>
<div class="settings-card">
<div class="setting-row">
<div>
<div class="setting-label">Syntax-Highlighting Theme</div>
<div class="setting-description">Farbschema für Code-Anzeige in GitLab</div>
</div>
<select v-model="selectedCodeTheme" @change="updateCodeTheme" class="theme-select">
<option v-for="theme in codeThemes" :key="theme.value" :value="theme.value">
{{ theme.label }}
</option>
</select>
</div>
</div>
</div>
<div class="settings-section">
<h3><i class="pi pi-key" style="margin-right: 0.5rem;"></i>Provider & API-Keys</h3>
<div class="settings-card">
<div class="setting-row">
<div>
<div class="setting-label">STT Provider</div>
<div class="setting-description">Speech-to-Text Anbieter für Spracheingabe</div>
</div>
<select v-model="userSettings.sttProvider" class="theme-select" style="min-width: 160px;">
<option value="openai">OpenAI Whisper</option>
<option value="custom">Custom URL</option>
</select>
</div>
<div class="setting-row">
<div>
<div class="setting-label">OpenAI API-Key</div>
<div class="setting-description">Für Whisper Transkription</div>
</div>
<input
v-model="userSettings.openaiApiKey"
type="password"
class="theme-select"
style="min-width: 280px;"
placeholder="sk-..."
/>
</div>
<div v-if="userSettings.sttProvider === 'custom'" class="setting-row">
<div>
<div class="setting-label">Custom STT URL</div>
<div class="setting-description">Eigener Transkriptions-Endpunkt</div>
</div>
<input
v-model="userSettings.customSttUrl"
class="theme-select"
style="min-width: 280px;"
placeholder="https://..."
/>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Telegram Bot-Token</div>
<div class="setting-description">Für das Senden von Aufgaben</div>
</div>
<input
v-model="userSettings.telegramBotToken"
type="password"
class="theme-select"
style="min-width: 280px;"
placeholder="123456:ABC..."
/>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Telegram Chat-ID</div>
<div class="setting-description">Standard-Chat für Nachrichten</div>
</div>
<input
v-model="userSettings.telegramDefaultChatId"
class="theme-select"
style="min-width: 280px;"
placeholder="z.B. 7150608398"
/>
</div>
<div class="setting-row" style="justify-content: flex-end;">
<button class="export-btn" @click="saveUserSettings" :disabled="savingUserSettings">
<i :class="savingUserSettings ? 'pi pi-spin pi-spinner' : 'pi pi-save'" style="margin-right: 0.4rem;"></i>
Speichern
</button>
</div>
</div>
</div>
<div class="settings-section">
<h3><i class="pi pi-info-circle" style="margin-right: 0.5rem;"></i>Version</h3>
<div class="settings-card">
<div class="setting-row">
<div class="setting-label">Frontend</div>
<div class="setting-value">{{ feVersion }}</div>
</div>
<div class="setting-row">
<div class="setting-label">Backend</div>
<div class="setting-value">{{ beVersion }}</div>
</div>
</div>
</div>
<div v-if="authStore.user?.role === 'admin'" class="settings-section">
<h3><i class="pi pi-download" style="margin-right: 0.5rem;"></i>Daten Export / Import</h3>
<div class="settings-card">
<div class="setting-row" v-if="exportStats">
<div>
<div class="setting-label">Datenbestand</div>
<div class="setting-description">
{{ exportStats.tasks || 0 }} Tasks, {{ exportStats.projects || 0 }} Projekte,
{{ exportStats.agents || 0 }} Agents, {{ exportStats.labels || 0 }} Labels,
{{ exportStats.logs || 0 }} Logs
</div>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Export</div>
<div class="setting-description">Alle Daten als JSON herunterladen</div>
</div>
<button class="export-btn" @click="exportData" :disabled="exportLoading">
<i :class="exportLoading ? 'pi pi-spin pi-spinner' : 'pi pi-download'" style="margin-right: 0.4rem;"></i>
Export
</button>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Import</div>
<div class="setting-description">JSON-Datei importieren (Merge-Modus)</div>
</div>
<label class="import-btn" :class="{ disabled: importLoading }">
<i :class="importLoading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'" style="margin-right: 0.4rem;"></i>
Import
<input type="file" accept=".json" @change="importData" hidden :disabled="importLoading" />
</label>
</div>
</div>
</div>
<div class="settings-section">
<h3><i class="pi pi-bolt" style="margin-right: 0.5rem;"></i>WebSocket</h3>
<div class="settings-card">
<div class="setting-row">
<div class="setting-label">Status</div>
<div class="setting-value">
<span class="ws-dot" :class="{ online: wsStore.isConnected }"></span>
{{ wsStore.isConnected ? 'Verbunden' : 'Getrennt' }}
</div>
</div>
<div class="setting-row">
<div class="setting-label">Verbunden seit</div>
<div class="setting-value">{{ wsUptime }}</div>
</div>
<div class="setting-row">
<div class="setting-label">Events empfangen</div>
<div class="setting-value">{{ wsStore.stats.eventsReceived }}</div>
</div>
<div class="setting-row">
<div class="setting-label">Events gesendet</div>
<div class="setting-value">{{ wsStore.stats.eventsSent }}</div>
</div>
<div class="setting-row">
<div class="setting-label">Reconnects</div>
<div class="setting-value">{{ Math.max(0, wsStore.stats.reconnects) }}</div>
</div>
<div class="setting-row" v-if="wsStore.stats.lastEventType">
<div class="setting-label">Letztes Event</div>
<div class="setting-value mono">{{ wsStore.stats.lastEventType }}</div>
</div>
</div>
<h4 class="subsection-title">Server</h4>
<div class="settings-card" v-if="serverStats">
<div class="setting-row">
<div class="setting-label">Uptime</div>
<div class="setting-value">{{ serverUptime }}</div>
</div>
<div class="setting-row">
<div class="setting-label">Verbindungen aktiv</div>
<div class="setting-value">{{ serverStats.currentConnections }}</div>
</div>
<div class="setting-row">
<div class="setting-label">Verbindungen gesamt</div>
<div class="setting-value">{{ serverStats.totalConnections }}</div>
</div>
<div class="setting-row">
<div class="setting-label">Broadcasts</div>
<div class="setting-value">{{ serverStats.totalBroadcasts }}</div>
</div>
<div class="setting-row">
<div class="setting-label">Nachrichten gesendet</div>
<div class="setting-value">{{ serverStats.totalMessagesSent }}</div>
</div>
<div class="setting-row">
<div class="setting-label">Nachrichten empfangen</div>
<div class="setting-value">{{ serverStats.totalMessagesReceived }}</div>
</div>
</div>
<h4 class="subsection-title" v-if="serverStats?.users?.length">Verbundene User</h4>
<div class="settings-card" v-if="serverStats?.users?.length">
<div class="setting-row" v-for="user in serverStats.users" :key="user.userId">
<div class="setting-label">
<span class="ws-dot online"></span> {{ user.username }}
</div>
<div class="setting-value mono">{{ user.messagesSent }} {{ user.messagesReceived }}</div>
</div>
</div>
</div>
<div v-if="authStore.user?.role === 'admin'" class="settings-section">
<h3>System</h3>
<div class="settings-card">
<div class="setting-row">
<div>
<div class="setting-label">Registrierung erlauben</div>
<div class="setting-description">Neue Benutzer können sich selbst registrieren</div>
</div>
<label class="toggle">
<input
type="checkbox"
v-model="allowRegistration"
@change="toggleRegistration"
:disabled="loading"
/>
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.settings-section {
margin-bottom: 2rem;
}
.settings-section h3 {
font-size: 1rem;
color: rgba(255,255,255,0.7);
margin-bottom: 0.75rem;
}
.settings-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
overflow: hidden;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.setting-row:last-child {
border-bottom: none;
}
.setting-label {
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.setting-description {
color: rgba(255, 255, 255, 0.5);
font-size: 0.875rem;
margin-top: 0.25rem;
}
.setting-value {
color: #fff;
font-weight: 500;
}
/* Toggle Switch */
.toggle {
position: relative;
display: inline-block;
width: 52px;
height: 28px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.2);
transition: 0.3s;
border-radius: 28px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 22px;
width: 22px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
.toggle input:checked + .toggle-slider {
background-color: #6366f1;
}
.toggle input:checked + .toggle-slider:before {
transform: translateX(24px);
}
.toggle input:disabled + .toggle-slider {
opacity: 0.5;
cursor: not-allowed;
}
/* Theme Select */
.theme-select {
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
font-size: 0.9rem;
cursor: pointer;
min-width: 200px;
}
.theme-select option {
background: #1a1a2e;
color: #fff;
}
.theme-select:hover {
border-color: rgba(255, 255, 255, 0.3);
}
.theme-select:focus {
outline: none;
border-color: #6366f1;
}
.ws-dot {
color: rgba(239, 68, 68, 0.8);
font-size: 0.65rem;
margin-right: 0.35rem;
}
.ws-dot.online {
color: rgba(34, 197, 94, 0.9);
}
.mono {
font-family: monospace;
font-size: 0.85rem;
}
.export-btn, .import-btn {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
background: rgba(99, 102, 241, 0.2);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
color: #a5b4fc;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.export-btn:hover, .import-btn:hover {
background: rgba(99, 102, 241, 0.3);
color: #fff;
}
.export-btn:disabled, .import-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.subsection-title {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
margin: 1rem 0 0.5rem 0;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,893 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
import AttachmentList from '../components/AttachmentList.vue'
import CommentList from '../components/CommentList.vue'
import TaskChangelog from '../components/TaskChangelog.vue'
import TaskCommits from '../components/TaskCommits.vue'
import SpeechToText from '../components/SpeechToText.vue'
interface Reminder {
enabled: boolean
datetime: string
interval: 'once' | 'daily' | 'hourly' | 'minutes'
intervalValue?: number
lastNotified?: string
}
interface Task {
_id: string
title: string
description?: string
status: string
priority: string
assignee?: string
project?: string
labels: string[]
reminder?: Reminder
}
interface Agent {
_id: string
name: string
emoji: string
}
interface Project {
_id: string
name: string
color: string
}
interface Label {
_id: string
name: string
color: string
}
const route = useRoute()
const router = useRouter()
const toast = useToast()
const taskId = computed(() => route.params.id as string)
const task = ref<Task | null>(null)
const editForm = ref({
title: '',
description: '',
status: 'todo',
priority: 'medium',
assignee: '',
project: '',
labels: [] as string[]
})
const agents = ref<Agent[]>([])
const projects = ref<Project[]>([])
const labels = ref<Label[]>([])
const loading = ref(true)
const saving = ref(false)
// Reminder state
const showReminderForm = ref(false)
const reminderDate = ref('')
const reminderTime = ref('')
const reminderInterval = ref<'once' | 'daily' | 'hourly' | 'minutes'>('once')
const reminderMinutes = ref(30)
const savingReminder = ref(false)
const statusOptions = [
{ value: 'backlog', label: 'Backlog', color: '#64748b' },
{ value: 'todo', label: 'To Do', color: '#3b82f6' },
{ value: 'in_progress', label: 'In Progress', color: '#f59e0b' },
{ value: 'review', label: 'Review', color: '#8b5cf6' },
{ value: 'done', label: 'Done', color: '#22c55e' }
]
const priorityOptions = [
{ value: 'low', label: 'Niedrig', color: '#64748b' },
{ value: 'medium', label: 'Mittel', color: '#3b82f6' },
{ value: 'high', label: 'Hoch', color: '#f59e0b' },
{ value: 'urgent', label: 'Dringend', color: '#ef4444' }
]
const intervalOptions = [
{ value: 'once', label: 'Einmalig' },
{ value: 'daily', label: 'Täglich' },
{ value: 'hourly', label: 'Stündlich' },
{ value: 'minutes', label: 'Alle X Minuten' }
]
const hasReminder = computed(() => task.value?.reminder?.enabled)
const reminderDisplay = computed(() => {
if (!task.value?.reminder) return null
const r = task.value.reminder
const dt = new Date(r.datetime)
const dateStr = dt.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const timeStr = dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
let intervalStr = ''
switch (r.interval) {
case 'once': intervalStr = 'Einmalig'; break
case 'daily': intervalStr = 'Täglich'; break
case 'hourly': intervalStr = 'Stündlich'; break
case 'minutes': intervalStr = `Alle ${r.intervalValue} Min`; break
}
return { date: dateStr, time: timeStr, interval: intervalStr, enabled: r.enabled }
})
// Status-Reihenfolge für "Nächster Status" Button
const statusOrder = ['backlog', 'todo', 'in_progress', 'review', 'done']
const initialStatus = ref('')
function getNextStatus(current: string): string | null {
const idx = statusOrder.indexOf(current)
if (idx < 0 || idx >= statusOrder.length - 1) return null
return statusOrder[idx + 1]
}
const nextStatusLabel = computed(() => {
const next = getNextStatus(editForm.value.status)
if (!next) return null
return statusOptions.find(o => o.value === next)?.label || next
})
async function advanceStatus() {
const next = getNextStatus(editForm.value.status)
if (!next) return
try {
await api.put(`/tasks/${taskId.value}`, { status: next })
editForm.value.status = next
if (task.value) task.value.status = next
toast.add({ severity: 'success', summary: 'Status geändert', detail: `${statusOptions.find(o => o.value === next)?.label}`, life: 2000 })
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
}
}
async function goToNextTask() {
if (!task.value?.project || !initialStatus.value) return
try {
const res = await api.get<{ tasks: Task[] }>('/tasks')
const sameTasks = res.tasks
.filter(t => t.project === task.value!.project && t.status === initialStatus.value && t._id !== taskId.value)
.sort((a, b) => a.title.localeCompare(b.title))
if (sameTasks.length > 0) {
router.push(`/tasks/${sameTasks[0]._id}`)
} else {
toast.add({ severity: 'info', summary: 'Kein weiterer Task', detail: 'Kein weiterer Task mit gleichem Status im Projekt', life: 3000 })
}
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Tasks konnten nicht geladen werden', life: 5000 })
}
}
async function loadTask() {
loading.value = true
try {
const [taskRes, agentsRes, projectsRes, labelsRes] = await Promise.all([
api.get<{ task: Task }>(`/tasks/${taskId.value}`),
api.get<{ agents: Agent[] }>('/agents'),
api.get<{ projects: Project[] }>('/tasks/projects/list'),
api.get<{ labels: Label[] }>('/labels')
])
task.value = taskRes.task
agents.value = agentsRes.agents
projects.value = projectsRes.projects
labels.value = labelsRes.labels
// Fill edit form
initialStatus.value = taskRes.task.status
editForm.value = {
title: taskRes.task.title,
description: taskRes.task.description || '',
status: taskRes.task.status,
priority: taskRes.task.priority,
assignee: taskRes.task.assignee || '',
project: taskRes.task.project || '',
labels: taskRes.task.labels || []
}
// Fill reminder form if exists
if (taskRes.task.reminder) {
const dt = new Date(taskRes.task.reminder.datetime)
reminderDate.value = dt.toISOString().split('T')[0]
reminderTime.value = dt.toTimeString().slice(0, 5)
reminderInterval.value = taskRes.task.reminder.interval
reminderMinutes.value = taskRes.task.reminder.intervalValue || 30
}
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Task nicht gefunden', life: 5000 })
router.push('/tasks')
} finally {
loading.value = false
}
}
async function saveTask() {
if (!editForm.value.title.trim()) {
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Titel ist erforderlich', life: 3000 })
return
}
saving.value = true
try {
await api.put(`/tasks/${taskId.value}`, editForm.value)
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Task aktualisiert', life: 3000 })
if (task.value) {
task.value = { ...task.value, ...editForm.value }
}
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
} finally {
saving.value = false
}
}
async function saveReminder() {
if (!reminderDate.value || !reminderTime.value) {
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Datum und Uhrzeit sind erforderlich', life: 3000 })
return
}
savingReminder.value = true
try {
const datetime = new Date(`${reminderDate.value}T${reminderTime.value}`)
await api.patch(`/tasks/${taskId.value}/reminder`, {
datetime: datetime.toISOString(),
interval: reminderInterval.value,
intervalValue: reminderInterval.value === 'minutes' ? reminderMinutes.value : undefined
})
// Update local task
if (task.value) {
task.value.reminder = {
enabled: true,
datetime: datetime.toISOString(),
interval: reminderInterval.value,
intervalValue: reminderMinutes.value
}
}
showReminderForm.value = false
toast.add({ severity: 'success', summary: 'Reminder gesetzt', detail: 'Du wirst erinnert', life: 3000 })
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
} finally {
savingReminder.value = false
}
}
async function deleteReminder() {
if (!confirm('Reminder löschen?')) return
try {
await api.delete(`/tasks/${taskId.value}/reminder`)
if (task.value) {
task.value.reminder = undefined
}
toast.add({ severity: 'success', summary: 'Entfernt', detail: 'Reminder gelöscht', life: 3000 })
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
}
}
function openReminderForm() {
// Set default values
if (!reminderDate.value) {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
reminderDate.value = tomorrow.toISOString().split('T')[0]
reminderTime.value = '09:00'
}
showReminderForm.value = true
}
function getStatusColor(status: string) {
return statusOptions.find(s => s.value === status)?.color || '#64748b'
}
function getPriorityColor(priority: string) {
return priorityOptions.find(p => p.value === priority)?.color || '#64748b'
}
onMounted(loadTask)
// Wenn sich die Route ändert (z.B. "Nächster Task"), Task neu laden
watch(() => route.params.id, (newId, oldId) => {
if (newId && newId !== oldId) {
loadTask()
}
})
</script>
<template>
<div>
<div class="back-link">
<router-link to="/tasks"> Zurück zu Tasks</router-link>
</div>
<div v-if="loading" class="loading">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
</div>
<div v-else-if="task" class="task-detail-layout">
<!-- LEFT: Edit Form -->
<div class="edit-panel">
<h2><i class="pi pi-pencil"></i> Task bearbeiten</h2>
<div class="form-group">
<label>Titel</label>
<div class="input-with-stt">
<input v-model="editForm.title" type="text" placeholder="Task-Titel" />
<SpeechToText @transcribed="(t: string) => editForm.title += (editForm.title ? ' ' : '') + t" />
</div>
</div>
<div class="form-group">
<label>Beschreibung</label>
<div class="input-with-stt">
<textarea v-model="editForm.description" rows="4" placeholder="Beschreibung (optional)"></textarea>
<SpeechToText @transcribed="(t: string) => editForm.description += (editForm.description ? ' ' : '') + t" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Status</label>
<select v-model="editForm.status">
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<div class="status-indicator" :style="{ background: getStatusColor(editForm.status) }"></div>
</div>
<div class="form-group">
<label>Priorität</label>
<select v-model="editForm.priority">
<option v-for="opt in priorityOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<div class="status-indicator" :style="{ background: getPriorityColor(editForm.priority) }"></div>
</div>
</div>
<div class="form-group">
<label>Agent</label>
<select v-model="editForm.assignee">
<option value="">Nicht zugewiesen</option>
<option v-for="agent in agents" :key="agent._id" :value="agent._id">
{{ agent.emoji }} {{ agent.name }}
</option>
</select>
</div>
<div class="form-group">
<label>Projekt</label>
<select v-model="editForm.project">
<option value="">Kein Projekt</option>
<option v-for="project in projects" :key="project._id" :value="project._id">
{{ project.name }}
</option>
</select>
</div>
<div class="form-group">
<label>Labels</label>
<select v-model="editForm.labels" multiple class="multi-select">
<option v-for="label in labels" :key="label._id" :value="label._id">
{{ label.name }}
</option>
</select>
<div class="labels-preview">
<span v-for="labelId in editForm.labels" :key="labelId"
class="label-badge"
:style="{ background: labels.find(l => l._id === labelId)?.color }">
{{ labels.find(l => l._id === labelId)?.name }}
</span>
</div>
</div>
<button @click="saveTask" class="save-btn" :disabled="saving">
<i class="pi" :class="saving ? 'pi-spin pi-spinner' : 'pi-check'"></i>
{{ saving ? 'Speichert...' : 'Änderungen speichern' }}
</button>
<!-- Quick Actions -->
<div class="quick-actions">
<button
v-if="nextStatusLabel"
@click="advanceStatus"
class="quick-btn next-status"
>
<i class="pi pi-arrow-right"></i>
{{ nextStatusLabel }}
</button>
<button
v-if="task?.project"
@click="goToNextTask"
class="quick-btn next-task"
>
<i class="pi pi-forward"></i>
Nächster Task
</button>
</div>
<!-- Reminder Section -->
<div class="reminder-section">
<h3><i class="pi pi-bell"></i> Reminder</h3>
<!-- Current Reminder Display -->
<div v-if="hasReminder && reminderDisplay" class="reminder-display">
<div class="reminder-info">
<span class="reminder-datetime">
<i class="pi pi-calendar"></i> {{ reminderDisplay.date }}
<i class="pi pi-clock"></i> {{ reminderDisplay.time }}
</span>
<span class="reminder-interval">{{ reminderDisplay.interval }}</span>
</div>
<div class="reminder-actions">
<button @click="openReminderForm" class="icon-btn" title="Bearbeiten">
<i class="pi pi-pencil"></i>
</button>
<button @click="deleteReminder" class="icon-btn delete" title="Löschen">
<i class="pi pi-trash"></i>
</button>
</div>
</div>
<!-- No Reminder -->
<div v-else-if="!showReminderForm" class="no-reminder">
<button @click="openReminderForm" class="add-reminder-btn">
<i class="pi pi-plus"></i> Reminder hinzufügen
</button>
</div>
<!-- Reminder Form -->
<div v-if="showReminderForm" class="reminder-form">
<div class="form-row">
<div class="form-group">
<label>Datum</label>
<input v-model="reminderDate" type="date" />
</div>
<div class="form-group">
<label>Uhrzeit</label>
<input v-model="reminderTime" type="time" />
</div>
</div>
<div class="form-group">
<label>Wiederholung</label>
<select v-model="reminderInterval">
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div v-if="reminderInterval === 'minutes'" class="form-group">
<label>Alle X Minuten</label>
<input v-model.number="reminderMinutes" type="number" min="1" max="1440" />
</div>
<div class="reminder-form-actions">
<button @click="showReminderForm = false" class="cancel-btn">Abbrechen</button>
<button @click="saveReminder" class="save-reminder-btn" :disabled="savingReminder">
<i class="pi" :class="savingReminder ? 'pi-spin pi-spinner' : 'pi-bell'"></i>
{{ savingReminder ? 'Speichert...' : 'Reminder setzen' }}
</button>
</div>
</div>
</div>
<!-- GitLab Commits Section -->
<TaskCommits
v-if="editForm.project"
:taskId="taskId"
:projectId="editForm.project"
/>
<!-- Attachments Section (Universal Component) -->
<div class="attachments-section">
<AttachmentList parentType="task" :parentId="taskId" :enablePaste="true" />
</div>
</div>
<!-- RIGHT: Comments + Changelog -->
<div class="comments-panel">
<CommentList parentType="task" :parentId="taskId" />
<TaskChangelog
:taskId="taskId"
:agents="agents"
:labels="labels"
:projects="projects"
/>
</div>
</div>
</div>
</template>
<style scoped>
.back-link {
margin-bottom: 1.5rem;
}
.back-link a {
color: #818cf8;
text-decoration: none;
font-weight: 500;
}
.back-link a:hover {
text-decoration: underline;
}
.loading {
text-align: center;
padding: 4rem;
}
/* Two-Column Layout */
.task-detail-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
min-height: calc(100vh - 200px);
}
@media (max-width: 1024px) {
.task-detail-layout {
grid-template-columns: 1fr;
}
}
/* Left Panel: Edit Form */
.edit-panel {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
}
.edit-panel h2 {
margin: 0 0 1.5rem;
font-size: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: #fff;
}
.form-group {
margin-bottom: 1.25rem;
position: relative;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.input-with-stt {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.input-with-stt input,
.input-with-stt textarea {
flex: 1;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
font-family: inherit;
font-size: 0.95rem;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.form-group select option {
background: #1a1a2e;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.status-indicator {
position: absolute;
right: 12px;
top: 38px;
width: 10px;
height: 10px;
border-radius: 50%;
}
.save-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.5rem;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: none;
border-radius: 8px;
color: #fff;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.save-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
}
.quick-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.quick-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.65rem 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.quick-btn.next-status {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.quick-btn.next-status:hover {
background: rgba(34, 197, 94, 0.25);
color: #fff;
}
.quick-btn.next-task {
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
color: #60a5fa;
}
.quick-btn.next-task:hover {
background: rgba(59, 130, 246, 0.25);
color: #fff;
}
.save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Reminder Section */
.reminder-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.reminder-section h3 {
margin: 0 0 1rem;
font-size: 1rem;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
gap: 0.5rem;
}
.reminder-display {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
padding: 0.75rem 1rem;
}
.reminder-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.reminder-datetime {
color: #fff;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.reminder-datetime i {
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
}
.reminder-interval {
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
}
.reminder-actions {
display: flex;
gap: 0.25rem;
}
.no-reminder {
text-align: center;
}
.add-reminder-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(99, 102, 241, 0.2);
border: 1px dashed rgba(99, 102, 241, 0.4);
border-radius: 8px;
color: #818cf8;
cursor: pointer;
font-size: 0.875rem;
}
.add-reminder-btn:hover {
background: rgba(99, 102, 241, 0.3);
}
.reminder-form {
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: 1rem;
}
.reminder-form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.cancel-btn {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
}
.cancel-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.save-reminder-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #f59e0b, #d97706);
border: none;
border-radius: 6px;
color: #fff;
font-weight: 500;
cursor: pointer;
}
.save-reminder-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Attachments */
.attachments-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Right Panel: Comments */
.comments-panel {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
display: flex;
flex-direction: column;
}
/* Icon buttons */
.icon-btn {
padding: 0.375rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
border-radius: 4px;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.icon-btn.delete:hover {
color: #f87171;
background: rgba(239, 68, 68, 0.2);
}
/* Labels */
.multi-select {
min-height: 100px;
padding: 0.5rem;
}
.multi-select option {
padding: 0.5rem;
margin: 0.25rem 0;
border-radius: 4px;
cursor: pointer;
}
.labels-preview {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.label-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
color: #fff;
font-weight: 500;
}
</style>

1356
src/views/TasksView.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,560 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
import { Line, Bar, Doughnut } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
ChartJS.register(
CategoryScale, LinearScale, PointElement, LineElement,
BarElement, ArcElement, Title, Tooltip, Legend, Filler
)
const toast = useToast()
interface Agent {
_id: string
name: string
emoji: string
}
interface TimeseriesEntry {
_id: string | { date: string; agentId: string; agentName: string }
totalTokens: number
inputTokens: number
outputTokens: number
totalCost: number
count: number
}
interface StatsEntry {
_id: string
totalTokens: number
totalCost: number
inputTokens: number
outputTokens: number
count: number
agentName?: string
}
interface Totals {
totalTokens: number
totalCost: number
inputTokens: number
outputTokens: number
count: number
}
const agents = ref<Agent[]>([])
const selectedAgent = ref('')
const selectedDays = ref(30)
const selectedPeriod = ref('month')
const timeseriesData = ref<TimeseriesEntry[]>([])
const statsData = ref<StatsEntry[]>([])
const totals = ref<Totals>({ totalTokens: 0, totalCost: 0, inputTokens: 0, outputTokens: 0, count: 0 })
const loading = ref(true)
const daysOptions = [
{ label: '7 Tage', value: 7 },
{ label: '14 Tage', value: 14 },
{ label: '30 Tage', value: 30 },
{ label: '90 Tage', value: 90 },
]
const periodOptions = [
{ label: 'Heute', value: 'day' },
{ label: 'Woche', value: 'week' },
{ label: 'Monat', value: 'month' },
]
// Chart: Token-Verlauf pro Tag (Line)
const lineChartData = computed(() => {
const labels = timeseriesData.value.map(d => {
const dateStr = typeof d._id === 'string' ? d._id : d._id.date
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
})
return {
labels,
datasets: [
{
label: 'Input Tokens',
data: timeseriesData.value.map(d => d.inputTokens),
borderColor: '#818cf8',
backgroundColor: 'rgba(129, 140, 248, 0.1)',
fill: true,
tension: 0.3,
},
{
label: 'Output Tokens',
data: timeseriesData.value.map(d => d.outputTokens),
borderColor: '#34d399',
backgroundColor: 'rgba(52, 211, 153, 0.1)',
fill: true,
tension: 0.3,
}
]
}
})
// Chart: Kosten pro Tag (Bar)
const costChartData = computed(() => {
const labels = timeseriesData.value.map(d => {
const dateStr = typeof d._id === 'string' ? d._id : d._id.date
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
})
return {
labels,
datasets: [{
label: 'Kosten ($)',
data: timeseriesData.value.map(d => +(d.totalCost || 0).toFixed(4)),
backgroundColor: 'rgba(251, 191, 36, 0.6)',
borderColor: '#fbbf24',
borderWidth: 1,
borderRadius: 4,
}]
}
})
// Chart: Verteilung pro Agent (Doughnut)
const agentChartData = computed(() => {
const colors = ['#818cf8', '#34d399', '#fbbf24', '#f87171', '#a78bfa', '#38bdf8', '#fb923c', '#4ade80']
return {
labels: statsData.value.map(s => s.agentName || String(s._id).substring(0, 8)),
datasets: [{
data: statsData.value.map(s => s.totalTokens),
backgroundColor: statsData.value.map((_, i) => colors[i % colors.length]),
borderWidth: 0,
}]
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: 'rgba(255,255,255,0.7)', font: { size: 12 } }
}
},
scales: {
x: {
ticks: { color: 'rgba(255,255,255,0.5)', font: { size: 11 } },
grid: { color: 'rgba(255,255,255,0.05)' }
},
y: {
ticks: { color: 'rgba(255,255,255,0.5)', font: { size: 11 } },
grid: { color: 'rgba(255,255,255,0.05)' }
}
}
}
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right' as const,
labels: { color: 'rgba(255,255,255,0.7)', font: { size: 12 }, padding: 12 }
}
}
}
function formatTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
return String(n)
}
async function loadAgents() {
try {
const res = await api.get<{ agents: Agent[] }>('/agents')
agents.value = res.agents || []
} catch { /* silent */ }
}
async function loadData() {
loading.value = true
try {
const agentParam = selectedAgent.value ? `&agentId=${selectedAgent.value}` : ''
const [tsRes, statsRes] = await Promise.all([
api.get<{ data: TimeseriesEntry[] }>(`/tokens/timeseries?days=${selectedDays.value}${agentParam}`),
api.get<{ stats: StatsEntry[]; totals: Totals }>(`/tokens/stats?period=${selectedPeriod.value}${agentParam}`)
])
timeseriesData.value = tsRes.data || []
statsData.value = statsRes.stats || []
totals.value = statsRes.totals || { totalTokens: 0, totalCost: 0, inputTokens: 0, outputTokens: 0, count: 0 }
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Analytics konnten nicht geladen werden', life: 3000 })
} finally {
loading.value = false
}
}
onMounted(() => {
loadAgents()
loadData()
})
</script>
<template>
<div class="analytics-page">
<div class="page-header">
<h1><i class="pi pi-chart-line" style="margin-right: 0.5rem;"></i>Token Analytics</h1>
<button class="refresh-btn" @click="loadData" :disabled="loading">
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'"></i>
</button>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-group">
<label>Agent</label>
<select v-model="selectedAgent" @change="loadData">
<option value="">Alle Agenten</option>
<option v-for="a in agents" :key="a._id" :value="a._id">{{ a.emoji }} {{ a.name }}</option>
</select>
</div>
<div class="filter-group">
<label>Zeitraum (Chart)</label>
<select v-model="selectedDays" @change="loadData">
<option v-for="o in daysOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<div class="filter-group">
<label>Statistik</label>
<select v-model="selectedPeriod" @change="loadData">
<option v-for="o in periodOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
</div>
<!-- KPI Cards -->
<div class="kpi-row">
<div class="kpi-card">
<div class="kpi-icon"><i class="pi pi-bolt"></i></div>
<div class="kpi-content">
<span class="kpi-value">{{ formatTokens(totals.totalTokens) }}</span>
<span class="kpi-label">Tokens gesamt</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon input"><i class="pi pi-arrow-right"></i></div>
<div class="kpi-content">
<span class="kpi-value">{{ formatTokens(totals.inputTokens) }}</span>
<span class="kpi-label">Input Tokens</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon output"><i class="pi pi-arrow-left"></i></div>
<div class="kpi-content">
<span class="kpi-value">{{ formatTokens(totals.outputTokens) }}</span>
<span class="kpi-label">Output Tokens</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon cost"><i class="pi pi-dollar"></i></div>
<div class="kpi-content">
<span class="kpi-value">${{ (totals.totalCost || 0).toFixed(2) }}</span>
<span class="kpi-label">Kosten</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon requests"><i class="pi pi-send"></i></div>
<div class="kpi-content">
<span class="kpi-value">{{ totals.count }}</span>
<span class="kpi-label">Anfragen</span>
</div>
</div>
</div>
<!-- Charts -->
<div v-if="loading" class="loading-state">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
</div>
<div v-else class="charts-grid">
<!-- Token Verlauf -->
<div class="chart-card wide">
<h3>Token-Verlauf</h3>
<div class="chart-container">
<Line v-if="timeseriesData.length" :data="lineChartData" :options="chartOptions" />
<div v-else class="empty-chart">Keine Daten im gewählten Zeitraum</div>
</div>
</div>
<!-- Kosten pro Tag -->
<div class="chart-card">
<h3>Kosten pro Tag</h3>
<div class="chart-container">
<Bar v-if="timeseriesData.length" :data="costChartData" :options="chartOptions" />
<div v-else class="empty-chart">Keine Daten</div>
</div>
</div>
<!-- Verteilung pro Agent -->
<div class="chart-card">
<h3>Verteilung pro Agent</h3>
<div class="chart-container">
<Doughnut v-if="statsData.length" :data="agentChartData" :options="doughnutOptions" />
<div v-else class="empty-chart">Keine Daten</div>
</div>
</div>
</div>
<!-- Agent-Tabelle -->
<div v-if="statsData.length" class="stats-table-card">
<h3>Nutzung pro Agent</h3>
<table class="stats-table">
<thead>
<tr>
<th>Agent</th>
<th>Anfragen</th>
<th>Input</th>
<th>Output</th>
<th>Gesamt</th>
<th>Kosten</th>
</tr>
</thead>
<tbody>
<tr v-for="s in statsData" :key="String(s._id)">
<td>{{ s.agentName || String(s._id).substring(0, 8) }}</td>
<td>{{ s.count }}</td>
<td>{{ formatTokens(s.inputTokens) }}</td>
<td>{{ formatTokens(s.outputTokens) }}</td>
<td>{{ formatTokens(s.totalTokens) }}</td>
<td>${{ (s.totalCost || 0).toFixed(4) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.analytics-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.25rem;
}
.page-header h1 {
margin: 0;
}
.refresh-btn {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.refresh-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
/* Filter */
.filter-bar {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-group label {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.filter-group select {
padding: 0.45rem 0.65rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #fff;
font-size: 0.85rem;
min-width: 140px;
}
/* KPI Cards */
.kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.kpi-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
.kpi-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
background: rgba(129, 140, 248, 0.15);
color: #818cf8;
flex-shrink: 0;
}
.kpi-icon.input { background: rgba(129, 140, 248, 0.15); color: #818cf8; }
.kpi-icon.output { background: rgba(52, 211, 153, 0.15); color: #34d399; }
.kpi-icon.cost { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
.kpi-icon.requests { background: rgba(248, 113, 113, 0.15); color: #f87171; }
.kpi-content {
display: flex;
flex-direction: column;
}
.kpi-value {
font-size: 1.3rem;
font-weight: 700;
color: #fff;
}
.kpi-label {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.45);
}
/* Charts */
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
.chart-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 1rem;
}
.chart-card.wide {
grid-column: 1 / -1;
}
.chart-card h3 {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
}
.chart-container {
height: 280px;
position: relative;
}
.empty-chart {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.3);
font-size: 0.9rem;
}
/* Stats Table */
.stats-table-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 1rem;
}
.stats-table-card h3 {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
}
.stats-table {
width: 100%;
border-collapse: collapse;
}
.stats-table th {
text-align: left;
padding: 0.6rem 0.75rem;
color: rgba(255, 255, 255, 0.5);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.stats-table td {
padding: 0.6rem 0.75rem;
color: rgba(255, 255, 255, 0.8);
font-size: 0.85rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.stats-table tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
/* Loading */
.loading-state {
text-align: center;
padding: 4rem;
color: rgba(255, 255, 255, 0.4);
}
/* Responsive */
@media (max-width: 768px) {
.charts-grid {
grid-template-columns: 1fr;
}
.chart-card.wide {
grid-column: 1;
}
.kpi-row {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

373
src/views/WorkspaceView.vue Normal file
View File

@@ -0,0 +1,373 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
import { api } from '../api'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import Textarea from 'primevue/textarea'
import Tag from 'primevue/tag'
import Toolbar from 'primevue/toolbar'
interface WorkspaceFile {
_id: string
agentId: string
agentName: string
filename: string
content?: string
updatedBy: string
updatedByName: string
createdAt: string
updatedAt: string
version: number
}
interface HistoryEntry {
_id: string
filename: string
content: string
version: number
changedBy: string
changedByName: string
changeType: string
createdAt: string
}
const toast = useToast()
const files = ref<WorkspaceFile[]>([])
const loading = ref(false)
const editDialogVisible = ref(false)
const historyDialogVisible = ref(false)
const selectedFile = ref<WorkspaceFile | null>(null)
const editContent = ref('')
const history = ref<HistoryEntry[]>([])
const historyLoading = ref(false)
const loadFiles = async () => {
loading.value = true
try {
const result = await api.get<{ files: WorkspaceFile[] }>('/workspace/files')
files.value = result.files
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Dateien konnten nicht geladen werden', life: 3000 })
} finally {
loading.value = false
}
}
const openFile = async (file: WorkspaceFile) => {
try {
const result = await api.get<{ file: WorkspaceFile }>(`/workspace/files/${file._id}`)
selectedFile.value = result.file
editContent.value = result.file.content || ''
editDialogVisible.value = true
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Datei konnte nicht geladen werden', life: 3000 })
}
}
const saveFile = async () => {
if (!selectedFile.value) return
try {
await api.put('/workspace/files', {
agentId: selectedFile.value.agentId,
agentName: selectedFile.value.agentName,
filename: selectedFile.value.filename,
content: editContent.value,
})
toast.add({ severity: 'success', summary: 'Gespeichert', detail: `${selectedFile.value.filename} aktualisiert`, life: 3000 })
editDialogVisible.value = false
await loadFiles()
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Speichern fehlgeschlagen', life: 3000 })
}
}
const openHistory = async (file: WorkspaceFile) => {
historyLoading.value = true
historyDialogVisible.value = true
try {
const result = await api.get<{ history: HistoryEntry[] }>(`/workspace/files/${file._id}/history`)
history.value = result.history
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'History konnte nicht geladen werden', life: 3000 })
} finally {
historyLoading.value = false
}
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const formatSize = (content: string | undefined) => {
if (!content) return ''
const bytes = new TextEncoder().encode(content).length
if (bytes < 1024) return `${bytes} B`
return `${(bytes / 1024).toFixed(1)} KB`
}
const fileIcon = (filename: string) => {
if (filename === 'SOUL.md') return 'pi pi-heart'
if (filename === 'MEMORY.md') return 'pi pi-database'
if (filename === 'IDENTITY.md') return 'pi pi-id-card'
if (filename === 'USER.md') return 'pi pi-user'
if (filename === 'TOOLS.md') return 'pi pi-wrench'
if (filename === 'WORKFLOW.md') return 'pi pi-list'
if (filename === 'CHECKLIST.md') return 'pi pi-check-square'
if (filename === 'HEARTBEAT.md') return 'pi pi-heart-fill'
if (filename === 'AGENTS.md') return 'pi pi-cog'
return 'pi pi-file'
}
onMounted(loadFiles)
</script>
<template>
<div class="workspace-view">
<div class="page-header">
<h2><i class="pi pi-folder-open"></i> Agent Workspace</h2>
<Button
label="Aktualisieren"
icon="pi pi-refresh"
severity="secondary"
size="small"
:loading="loading"
@click="loadFiles"
/>
</div>
<div class="card">
<DataTable
:value="files"
:loading="loading"
class="p-datatable-sm"
sortField="filename"
:sortOrder="1"
>
<Column field="filename" header="Datei" sortable>
<template #body="{ data }">
<div class="flex items-center gap-2">
<i :class="fileIcon(data.filename)" />
<span class="font-mono font-semibold">{{ data.filename }}</span>
</div>
</template>
</Column>
<Column field="agentName" header="Agent" sortable>
<template #body="{ data }">
<Tag :value="data.agentName" severity="info" />
</template>
</Column>
<Column field="version" header="Version" sortable>
<template #body="{ data }">
<Tag :value="`v${data.version}`" severity="secondary" />
</template>
</Column>
<Column field="updatedByName" header="Zuletzt geändert von" sortable />
<Column field="updatedAt" header="Aktualisiert" sortable>
<template #body="{ data }">
{{ formatDate(data.updatedAt) }}
</template>
</Column>
<Column header="Aktionen" :exportable="false" style="min-width: 12rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button
icon="pi pi-pencil"
severity="info"
size="small"
rounded
text
@click="openFile(data)"
v-tooltip="'Bearbeiten'"
/>
<Button
icon="pi pi-history"
severity="secondary"
size="small"
rounded
text
@click="openHistory(data)"
v-tooltip="'History'"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<!-- Edit Dialog -->
<Dialog
v-model:visible="editDialogVisible"
:header="selectedFile?.filename || 'Datei bearbeiten'"
modal
:style="{ width: '80vw', maxWidth: '1200px' }"
class="dark-dialog"
>
<div class="file-meta">
<span><i class="pi pi-user"></i> {{ selectedFile?.updatedByName }}</span>
<span><i class="pi pi-clock"></i> {{ selectedFile ? formatDate(selectedFile.updatedAt) : '' }}</span>
<Tag :value="`v${selectedFile?.version}`" severity="secondary" />
</div>
<Textarea
v-model="editContent"
rows="25"
class="file-editor"
autoResize
/>
<template #footer>
<button class="dialog-btn" @click="editDialogVisible = false">Abbrechen</button>
<button class="dialog-btn primary" @click="saveFile">Speichern</button>
</template>
</Dialog>
<!-- History Dialog -->
<Dialog
v-model:visible="historyDialogVisible"
header="Änderungsverlauf"
modal
:style="{ width: '60vw', maxWidth: '900px' }"
class="dark-dialog"
>
<DataTable :value="history" :loading="historyLoading" class="p-datatable-sm">
<Column field="version" header="Version">
<template #body="{ data }">
<Tag :value="`v${data.version}`" severity="secondary" />
</template>
</Column>
<Column field="changeType" header="Typ">
<template #body="{ data }">
<Tag
:value="data.changeType"
:severity="data.changeType === 'create' ? 'success' : data.changeType === 'restore' ? 'warn' : 'info'"
/>
</template>
</Column>
<Column field="changedByName" header="Geändert von" />
<Column field="createdAt" header="Datum">
<template #body="{ data }">
{{ formatDate(data.createdAt) }}
</template>
</Column>
</DataTable>
</Dialog>
</div>
</template>
<style scoped>
.workspace-view {
padding: 1.5rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h2 {
margin: 0;
color: #e0e0e0;
font-size: 1.4rem;
}
.page-header h2 i {
margin-right: 0.5rem;
}
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.file-meta {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
color: #aaa;
font-size: 0.875rem;
}
.file-meta i {
margin-right: 0.25rem;
}
.file-editor {
width: 100%;
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 0.875rem;
min-height: 400px;
background: #2a3144 !important;
color: #d4d4d4 !important;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
border-radius: 8px;
padding: 1rem !important;
line-height: 1.6;
}
.font-mono {
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
}
:deep(.p-datatable) {
background: transparent;
}
:deep(.p-datatable .p-datatable-thead > tr > th) {
background: rgba(255, 255, 255, 0.06);
color: #aab;
border-color: rgba(255, 255, 255, 0.08);
font-weight: 600;
}
:deep(.p-datatable .p-datatable-tbody > tr) {
background: transparent;
color: #d0d0d0;
}
:deep(.p-datatable .p-datatable-tbody > tr > td) {
border-color: rgba(255, 255, 255, 0.05);
}
:deep(.p-datatable .p-datatable-tbody > tr:hover) {
background: rgba(255, 255, 255, 0.06) !important;
}
.dialog-btn {
padding: 0.45rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.8);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.dialog-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.dialog-btn.primary {
background: rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.3);
color: #a5b4fc;
}
.dialog-btn.primary:hover {
background: rgba(99, 102, 241, 0.35);
color: #fff;
}
</style>

9
vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173
}
})