Files
nexus/frontend/src/views/NotificationsView.vue
T
devops 5df5194651
CI - Build & Test / Backend (.NET) (push) Successful in 39s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
fix: deploy GatewayBridge + Dashboard SSE live endpoints
Root cause: /api/bridge/health and /api/dashboard/live returned 404 live because
the source files existed on disk but were never committed to git. The CD pipeline
deploys from committed main, so the deployed containers lacked these endpoints.

Changes:
- Add GatewayBridgeController with /api/bridge/health endpoint
- Add DashboardController Live() SSE endpoint for /api/dashboard/live
- Add LiveUpdateService (in-memory pub/sub for SSE updates)
- Add TaskBridgeService (structured agent-to-backend bridge)
- Add nginx routing blocks for /api/bridge/ and /api/dashboard/live
- Add host-level nginx-nexus.conf blocks for bridge + live pass-through
- Update ServiceCollectionExtensions with DI registrations
- Update Dashboard.cs model with SSE-related DTOs

Verification after deploy:
  curl https://nexus.noveria.net/api/bridge/health → 200 JSON
  curl https://nexus.noveria.net/api/dashboard/live → 200 SSE stream
2026-06-22 20:43:31 +02:00

254 lines
5.4 KiB
Vue

<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNotificationStore } from '../stores/notifications'
import { useLiveSyncStore } from '../stores/liveSync'
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
const store = useNotificationStore()
const router = useRouter()
const liveSyncStore = useLiveSyncStore()
const sortedNotifications = computed(() => {
return [...store.notifications].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
})
function typeIcon(type: string): string {
switch (type) {
case 'task_assigned': return '👤'
case 'task_review': return '✅'
case 'task_blocked': return '🚫'
default: return '🔔'
}
}
function typeColor(type: string): string {
switch (type) {
case 'task_assigned': return '#4d8cf6'
case 'task_review': return '#f6a84d'
case 'task_blocked': return '#e16e75'
default: return '#7b6ef2'
}
}
function timeAgo(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diffSec = Math.floor((now - then) / 1000)
if (diffSec < 60) return 'vor ' + diffSec + ' Sek'
const diffMin = Math.floor(diffSec / 60)
if (diffMin < 60) return 'vor ' + diffMin + ' Min'
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return 'vor ' + diffHr + ' Std'
const diffDay = Math.floor(diffHr / 24)
return 'vor ' + diffDay + ' Tag' + (diffDay > 1 ? 'en' : '')
}
function onNotificationClick(n: { id: string, taskId: string | null }) {
if (n.taskId) {
router.push('/tasks')
}
store.markAsRead(n.id)
}
onMounted(() => {
store.startListPolling()
liveSyncStore.connect()
})
onUnmounted(() => {
store.stopListPolling()
liveSyncStore.disconnect()
})
</script>
<template>
<div class="notifications-page">
<div class="page-header">
<h1>
<Bell :size="22" />
Benachrichtigungen
</h1>
<button
v-if="store.unreadCount > 0"
class="mark-all-btn"
@click="store.markAllAsRead()"
>
<CheckCheck :size="15" />
Alle als gelesen markieren
</button>
</div>
<div v-if="sortedNotifications.length === 0" class="empty-state">
<BellOff :size="48" />
<p>Keine Benachrichtigungen</p>
</div>
<div v-else class="notification-list">
<div
v-for="n in sortedNotifications"
:key="n.id"
:class="['notification-card', { unread: !n.isRead }]"
@click="onNotificationClick(n)"
>
<div class="icon-wrapper" :style="{ background: typeColor(n.type) + '20' }">
<span class="type-icon">{{ typeIcon(n.type) }}</span>
</div>
<div class="card-body">
<div :class="['card-title', { bold: !n.isRead }]">{{ n.title }}</div>
<div v-if="n.message" class="card-message">{{ n.message }}</div>
</div>
<div class="card-meta">
<span class="timestamp">{{ timeAgo(n.createdAt) }}</span>
<ChevronRight v-if="n.taskId" :size="14" class="arrow" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.notifications-page {
max-width: 720px;
margin: 0 auto;
padding: 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
gap: 12px;
}
.page-header h1 {
margin: 0;
font-size: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.mark-all-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--nx-line, #1f2330);
border-radius: 6px;
background: transparent;
color: var(--nx-text-dim, #6f7889);
font-size: 10.5px;
cursor: pointer;
transition: background .15s, color .15s;
}
.mark-all-btn:hover {
background: var(--nx-accent-soft, rgba(123, 110, 242, .08));
color: #d8dbe3;
}
/* ── Empty State ── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
color: var(--nx-text-dim, #6f7889);
gap: 16px;
}
.empty-state p {
font-size: 14px;
margin: 0;
}
/* ── Notification List ── */
.notification-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.notification-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border-radius: 8px;
cursor: pointer;
transition: background .15s;
border: 1px solid transparent;
}
.notification-card:hover {
background: var(--nx-accent-soft, rgba(123, 110, 242, .06));
}
.notification-card.unread {
background: rgba(77, 140, 246, .06);
border-color: rgba(77, 140, 246, .12);
}
.icon-wrapper {
width: 36px;
height: 36px;
border-radius: 8px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.type-icon {
font-size: 16px;
line-height: 1;
}
.card-body {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 12.5px;
color: #d8dbe3;
line-height: 1.4;
}
.card-title.bold {
font-weight: 700;
color: #fff;
}
.card-message {
font-size: 10.5px;
color: var(--nx-text-dim, #6f7889);
margin-top: 3px;
line-height: 1.3;
}
.card-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
min-width: 60px;
}
.timestamp {
font-size: 9px;
color: var(--nx-text-dim, #6f7889);
white-space: nowrap;
}
.arrow {
color: var(--nx-text-dim, #6f7889);
opacity: .5;
}
</style>