kin: auto-commit after pipeline
This commit is contained in:
parent
a9d3086139
commit
7ee520e18e
5 changed files with 597 additions and 26 deletions
|
|
@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|||
import { api, type EscalationNotification } from '../api'
|
||||
|
||||
const STORAGE_KEY = 'kin_dismissed_escalations'
|
||||
const WATCHDOG_TOAST_KEY = 'kin_dismissed_watchdog_toasts'
|
||||
|
||||
const notifications = ref<EscalationNotification[]>([])
|
||||
const showPanel = ref(false)
|
||||
|
|
@ -27,9 +28,62 @@ const visible = computed(() =>
|
|||
notifications.value.filter(n => !dismissed.value.has(n.task_id))
|
||||
)
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
function saveDismissedWatchdog(ids: Set<string>) {
|
||||
localStorage.setItem(WATCHDOG_TOAST_KEY, JSON.stringify([...ids]))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
notifications.value = await api.notifications()
|
||||
const fresh = await api.notifications()
|
||||
checkWatchdogToasts(fresh)
|
||||
notifications.value = fresh
|
||||
} catch {
|
||||
// silent — не ломаем layout при недоступном endpoint
|
||||
}
|
||||
|
|
@ -67,6 +121,24 @@ onUnmounted(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 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">
|
||||
<p class="text-xs leading-snug">Watchdog: задача <span class="font-mono font-semibold">{{ toast.task_id }}</span> заблокирована — {{ toast.reason }}</p>
|
||||
</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>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Badge-кнопка — видна только при наличии активных эскалаций -->
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -849,7 +849,8 @@ async function addDecision() {
|
|||
<div v-else class="space-y-1">
|
||||
<router-link v-for="t in filteredTasks" :key="t.id"
|
||||
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
|
||||
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors">
|
||||
class="flex flex-col gap-0.5 px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
|
||||
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
|
||||
|
|
@ -899,6 +900,8 @@ async function addDecision() {
|
|||
↩ Revise
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="t.status === 'blocked' && t.blocked_reason" class="text-xs text-red-400 truncate">{{ t.blocked_reason }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1130,7 +1133,8 @@ async function addDecision() {
|
|||
class="block px-2.5 py-2 bg-gray-900 border border-gray-800 rounded text-xs hover:border-gray-600 no-underline cursor-grab active:cursor-grabbing transition-colors select-none"
|
||||
:class="draggingTaskId === t.id ? 'opacity-40' : ''">
|
||||
<div class="text-gray-500 mb-1 text-[10px]">{{ t.id }}</div>
|
||||
<div class="text-gray-300 leading-snug mb-1.5">{{ t.title }}</div>
|
||||
<div class="text-gray-300 leading-snug mb-1">{{ t.title }}</div>
|
||||
<div v-if="t.status === 'blocked' && t.blocked_reason" class="text-xs text-red-400 truncate mb-1">{{ t.blocked_reason }}</div>
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<Badge v-if="t.category" :text="t.category" :color="CATEGORY_COLORS[t.category] || 'gray'" />
|
||||
<span class="text-gray-600 text-[10px] ml-auto">p{{ t.priority }}</span>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue