2026-03-16 09:13:34 +02:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
2026-03-18 07:57:15 +02:00
|
|
|
import { useI18n } from 'vue-i18n'
|
2026-03-16 09:13:34 +02:00
|
|
|
import { api, type EscalationNotification } from '../api'
|
|
|
|
|
|
2026-03-18 07:57:15 +02:00
|
|
|
const { t, locale } = useI18n()
|
|
|
|
|
|
2026-03-16 09:13:34 +02:00
|
|
|
const STORAGE_KEY = 'kin_dismissed_escalations'
|
2026-03-17 16:14:35 +02:00
|
|
|
const WATCHDOG_TOAST_KEY = 'kin_dismissed_watchdog_toasts'
|
2026-03-16 09:13:34 +02:00
|
|
|
|
|
|
|
|
const notifications = ref<EscalationNotification[]>([])
|
|
|
|
|
const showPanel = ref(false)
|
|
|
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
|
|
|
|
|
function loadDismissed(): Set<string> {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(STORAGE_KEY)
|
|
|
|
|
return new Set(raw ? JSON.parse(raw) : [])
|
|
|
|
|
} catch {
|
|
|
|
|
return new Set()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveDismissed(ids: Set<string>) {
|
|
|
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dismissed = ref<Set<string>>(loadDismissed())
|
|
|
|
|
|
|
|
|
|
const visible = computed(() =>
|
|
|
|
|
notifications.value.filter(n => !dismissed.value.has(n.task_id))
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-17 16:14:35 +02:00
|
|
|
// Watchdog toasts
|
|
|
|
|
interface WatchdogToast {
|
|
|
|
|
task_id: string
|
|
|
|
|
reason: string
|
|
|
|
|
timerId: ReturnType<typeof setTimeout> | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const watchdogToasts = ref<WatchdogToast[]>([])
|
|
|
|
|
|
|
|
|
|
function loadDismissedWatchdog(): Set<string> {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(WATCHDOG_TOAST_KEY)
|
|
|
|
|
return new Set(raw ? JSON.parse(raw) : [])
|
|
|
|
|
} catch {
|
|
|
|
|
return new Set()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 16:30:24 +02:00
|
|
|
const WATCHDOG_MAX_STORED = 100
|
|
|
|
|
|
2026-03-17 16:14:35 +02:00
|
|
|
function saveDismissedWatchdog(ids: Set<string>) {
|
2026-03-17 16:30:24 +02:00
|
|
|
localStorage.setItem(WATCHDOG_TOAST_KEY, JSON.stringify([...ids].slice(-WATCHDOG_MAX_STORED)))
|
2026-03-17 16:14:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dismissedWatchdog = ref<Set<string>>(loadDismissedWatchdog())
|
|
|
|
|
|
|
|
|
|
function isWatchdogReason(reason: string): boolean {
|
|
|
|
|
return reason.includes('Process died') || reason.includes('Parent process died')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dismissWatchdogToast(taskId: string) {
|
|
|
|
|
const idx = watchdogToasts.value.findIndex(t => t.task_id === taskId)
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
const toast = watchdogToasts.value[idx]
|
|
|
|
|
if (toast.timerId) clearTimeout(toast.timerId)
|
|
|
|
|
watchdogToasts.value.splice(idx, 1)
|
|
|
|
|
}
|
|
|
|
|
const newSet = new Set([...dismissedWatchdog.value, taskId])
|
|
|
|
|
dismissedWatchdog.value = newSet
|
|
|
|
|
saveDismissedWatchdog(newSet)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function checkWatchdogToasts(fresh: EscalationNotification[]) {
|
|
|
|
|
const activeIds = new Set(watchdogToasts.value.map(t => t.task_id))
|
|
|
|
|
for (const n of fresh) {
|
|
|
|
|
if (isWatchdogReason(n.reason) && !dismissedWatchdog.value.has(n.task_id) && !activeIds.has(n.task_id)) {
|
|
|
|
|
const toast: WatchdogToast = { task_id: n.task_id, reason: n.reason, timerId: null }
|
|
|
|
|
toast.timerId = setTimeout(() => dismissWatchdogToast(n.task_id), 8000)
|
|
|
|
|
watchdogToasts.value.push(toast)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 09:13:34 +02:00
|
|
|
async function load() {
|
|
|
|
|
try {
|
2026-03-17 16:14:35 +02:00
|
|
|
const fresh = await api.notifications()
|
|
|
|
|
checkWatchdogToasts(fresh)
|
|
|
|
|
notifications.value = fresh
|
2026-03-16 09:13:34 +02:00
|
|
|
} catch {
|
|
|
|
|
// silent — не ломаем layout при недоступном endpoint
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dismiss(taskId: string) {
|
|
|
|
|
dismissed.value = new Set([...dismissed.value, taskId])
|
|
|
|
|
saveDismissed(dismissed.value)
|
|
|
|
|
if (visible.value.length === 0) showPanel.value = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dismissAll() {
|
|
|
|
|
const newSet = new Set([...dismissed.value, ...visible.value.map(n => n.task_id)])
|
|
|
|
|
dismissed.value = newSet
|
|
|
|
|
saveDismissed(newSet)
|
|
|
|
|
showPanel.value = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatTime(iso: string): string {
|
|
|
|
|
try {
|
2026-03-18 07:57:15 +02:00
|
|
|
return new Date(iso).toLocaleString(locale.value === 'ru' ? 'ru-RU' : 'en-US', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
2026-03-16 09:13:34 +02:00
|
|
|
} catch {
|
|
|
|
|
return iso
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
await load()
|
|
|
|
|
pollTimer = setInterval(load, 10000)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
if (pollTimer) clearInterval(pollTimer)
|
2026-03-17 16:30:24 +02:00
|
|
|
// KIN-099: clear watchdog toast auto-dismiss timers to prevent memory leaks
|
|
|
|
|
for (const toast of watchdogToasts.value) {
|
|
|
|
|
if (toast.timerId) clearTimeout(toast.timerId)
|
|
|
|
|
}
|
2026-03-16 09:13:34 +02:00
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-03-17 16:14:35 +02:00
|
|
|
<!-- Watchdog toast notifications -->
|
|
|
|
|
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
|
|
|
|
<div
|
|
|
|
|
v-for="toast in watchdogToasts"
|
|
|
|
|
:key="toast.task_id"
|
|
|
|
|
class="pointer-events-auto flex items-start gap-2 px-3 py-2.5 border border-red-700 bg-red-950/40 text-red-400 rounded-lg shadow-xl max-w-sm"
|
|
|
|
|
>
|
|
|
|
|
<span class="shrink-0 text-sm">⚠</span>
|
|
|
|
|
<div class="flex-1 min-w-0">
|
2026-03-18 07:57:15 +02:00
|
|
|
<p class="text-xs leading-snug">{{ t('escalation.watchdog_blocked', { task_id: toast.task_id, reason: toast.reason }) }}</p>
|
2026-03-17 16:14:35 +02:00
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
@click="dismissWatchdogToast(toast.task_id)"
|
|
|
|
|
class="shrink-0 text-red-400/60 hover:text-red-300 text-lg leading-none mt-[-2px] bg-transparent border-none cursor-pointer"
|
|
|
|
|
>×</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-16 09:13:34 +02:00
|
|
|
<div class="relative">
|
|
|
|
|
<!-- Badge-кнопка — видна только при наличии активных эскалаций -->
|
|
|
|
|
<button
|
|
|
|
|
v-if="visible.length > 0"
|
|
|
|
|
@click="showPanel = !showPanel"
|
|
|
|
|
class="relative flex items-center gap-1.5 px-2.5 py-1 text-xs bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<span class="inline-block w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>
|
2026-03-18 07:57:15 +02:00
|
|
|
{{ t('escalation.escalations') }}
|
2026-03-16 09:13:34 +02:00
|
|
|
<span class="ml-0.5 font-bold">{{ visible.length }}</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<!-- Панель уведомлений -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="showPanel && visible.length > 0"
|
|
|
|
|
class="absolute right-0 top-full mt-2 w-96 bg-gray-900 border border-red-900/60 rounded-lg shadow-2xl z-50"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
|
2026-03-18 07:57:15 +02:00
|
|
|
<span class="text-xs font-semibold text-red-400">{{ t('escalation.escalations_panel_title') }}</span>
|
2026-03-16 09:13:34 +02:00
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
@click="dismissAll"
|
|
|
|
|
class="text-xs text-gray-500 hover:text-gray-300"
|
2026-03-18 07:57:15 +02:00
|
|
|
>{{ t('escalation.dismiss_all') }}</button>
|
2026-03-16 09:13:34 +02:00
|
|
|
<button @click="showPanel = false" class="text-gray-500 hover:text-gray-300 text-lg leading-none">×</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="max-h-80 overflow-y-auto divide-y divide-gray-800">
|
|
|
|
|
<div
|
|
|
|
|
v-for="n in visible"
|
|
|
|
|
:key="n.task_id"
|
|
|
|
|
class="px-4 py-3"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-start justify-between gap-2">
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
<div class="flex items-center gap-1.5 mb-1">
|
|
|
|
|
<span class="text-xs font-mono text-red-400 shrink-0">{{ n.task_id }}</span>
|
|
|
|
|
<span class="text-xs text-gray-500">·</span>
|
|
|
|
|
<span class="text-xs text-orange-400 shrink-0">{{ n.agent_role }}</span>
|
|
|
|
|
<span v-if="n.pipeline_step" class="text-xs text-gray-600 truncate">@ {{ n.pipeline_step }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="text-xs text-gray-300 leading-snug break-words">{{ n.reason }}</p>
|
|
|
|
|
<p class="text-xs text-gray-600 mt-1">{{ formatTime(n.blocked_at) }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
@click="dismiss(n.task_id)"
|
|
|
|
|
class="shrink-0 px-2 py-1 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 hover:text-gray-200"
|
2026-03-18 07:57:15 +02:00
|
|
|
>{{ t('escalation.dismiss') }}</button>
|
2026-03-16 09:13:34 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Overlay для закрытия панели -->
|
|
|
|
|
<div v-if="showPanel" class="fixed inset-0 z-40" @click="showPanel = false"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|