kin: KIN-125-frontend_dev
This commit is contained in:
parent
49ea6542b8
commit
24fd8ca72d
3 changed files with 217 additions and 30 deletions
|
|
@ -1,16 +1,20 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { api, type EscalationNotification } from '../api'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { api, type EscalationNotification, type Task } from '../api'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const STORAGE_KEY = 'kin_dismissed_escalations'
|
const STORAGE_KEY = 'kin_dismissed_escalations'
|
||||||
const WATCHDOG_TOAST_KEY = 'kin_dismissed_watchdog_toasts'
|
const WATCHDOG_TOAST_KEY = 'kin_dismissed_watchdog_toasts'
|
||||||
|
const COMPLETED_STORAGE_KEY = 'kin_dismissed_completed'
|
||||||
|
|
||||||
const notifications = ref<EscalationNotification[]>([])
|
const notifications = ref<EscalationNotification[]>([])
|
||||||
const showPanel = ref(false)
|
const showPanel = ref(false)
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let completedPollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
function loadDismissed(): Set<string> {
|
function loadDismissed(): Set<string> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -115,13 +119,99 @@ function formatTime(iso: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Completed tasks ---
|
||||||
|
|
||||||
|
interface CompletedTaskItem extends Task {
|
||||||
|
project_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedTasks = ref<CompletedTaskItem[]>([])
|
||||||
|
|
||||||
|
function loadDismissedCompleted(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COMPLETED_STORAGE_KEY)
|
||||||
|
return new Set(raw ? JSON.parse(raw) : [])
|
||||||
|
} catch {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDismissedCompleted(ids: Set<string>) {
|
||||||
|
localStorage.setItem(COMPLETED_STORAGE_KEY, JSON.stringify([...ids]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissedCompleted = ref<Set<string>>(loadDismissedCompleted())
|
||||||
|
|
||||||
|
const visibleCompleted = computed(() =>
|
||||||
|
completedTasks.value.filter(t => !dismissedCompleted.value.has(t.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadCompletedTasks() {
|
||||||
|
try {
|
||||||
|
const projects = await api.projects()
|
||||||
|
const withCompleted = projects.filter(p => p.done_tasks > 0)
|
||||||
|
if (withCompleted.length === 0) {
|
||||||
|
completedTasks.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const details = await Promise.all(withCompleted.map(p => api.project(p.id)))
|
||||||
|
const results: CompletedTaskItem[] = []
|
||||||
|
for (let i = 0; i < details.length; i++) {
|
||||||
|
const projectName = withCompleted[i].name
|
||||||
|
for (const task of details[i].tasks) {
|
||||||
|
if (task.status === 'completed') {
|
||||||
|
results.push({ ...task, project_name: projectName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||||
|
completedTasks.value = results.slice(0, 20)
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doneCompleted(taskId: string) {
|
||||||
|
dismissedCompleted.value = new Set([...dismissedCompleted.value, taskId])
|
||||||
|
saveDismissedCompleted(dismissedCompleted.value)
|
||||||
|
if (visibleCompleted.value.length === 0 && visible.value.length === 0) showPanel.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisingTaskId = ref<string | null>(null)
|
||||||
|
const reviseComment = ref('')
|
||||||
|
|
||||||
|
function startRevise(taskId: string) {
|
||||||
|
revisingTaskId.value = taskId
|
||||||
|
reviseComment.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRevise(taskId: string) {
|
||||||
|
try {
|
||||||
|
await api.reviseTask(taskId, reviseComment.value || t('escalation.revise_default_comment'))
|
||||||
|
doneCompleted(taskId)
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
revisingTaskId.value = null
|
||||||
|
reviseComment.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRevise() {
|
||||||
|
revisingTaskId.value = null
|
||||||
|
reviseComment.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await load()
|
await load()
|
||||||
pollTimer = setInterval(load, 10000)
|
pollTimer = setInterval(load, 10000)
|
||||||
|
await loadCompletedTasks()
|
||||||
|
completedPollTimer = setInterval(loadCompletedTasks, 30000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
if (completedPollTimer) clearInterval(completedPollTimer)
|
||||||
// KIN-099: clear watchdog toast auto-dismiss timers to prevent memory leaks
|
// KIN-099: clear watchdog toast auto-dismiss timers to prevent memory leaks
|
||||||
for (const toast of watchdogToasts.value) {
|
for (const toast of watchdogToasts.value) {
|
||||||
if (toast.timerId) clearTimeout(toast.timerId)
|
if (toast.timerId) clearTimeout(toast.timerId)
|
||||||
|
|
@ -148,8 +238,8 @@ onUnmounted(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative flex items-center gap-1">
|
||||||
<!-- Badge-кнопка — видна только при наличии активных эскалаций -->
|
<!-- Badge-кнопка эскалаций -->
|
||||||
<button
|
<button
|
||||||
v-if="visible.length > 0"
|
v-if="visible.length > 0"
|
||||||
@click="showPanel = !showPanel"
|
@click="showPanel = !showPanel"
|
||||||
|
|
@ -160,43 +250,124 @@ onUnmounted(() => {
|
||||||
<span class="ml-0.5 font-bold">{{ visible.length }}</span>
|
<span class="ml-0.5 font-bold">{{ visible.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Панель уведомлений -->
|
<!-- Badge-кнопка завершённых задач -->
|
||||||
|
<button
|
||||||
|
v-if="visibleCompleted.length > 0"
|
||||||
|
@click="showPanel = !showPanel"
|
||||||
|
class="relative flex items-center gap-1.5 px-2.5 py-1 text-xs bg-green-900/40 text-green-400 border border-green-800 rounded hover:bg-green-900/60 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="inline-block w-1.5 h-1.5 bg-green-500 rounded-full"></span>
|
||||||
|
{{ t('escalation.completed_tasks') }}
|
||||||
|
<span class="ml-0.5 font-bold">{{ visibleCompleted.length }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Объединённая панель уведомлений -->
|
||||||
<div
|
<div
|
||||||
v-if="showPanel && visible.length > 0"
|
v-if="showPanel && (visible.length > 0 || visibleCompleted.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"
|
class="absolute right-0 top-full mt-2 w-96 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl z-50"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
|
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
|
||||||
<span class="text-xs font-semibold text-red-400">{{ t('escalation.escalations_panel_title') }}</span>
|
<span class="text-xs font-semibold text-gray-400">Kin</span>
|
||||||
<div class="flex items-center gap-2">
|
<button @click="showPanel = false" class="text-gray-500 hover:text-gray-300 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Секция эскалаций -->
|
||||||
|
<div v-if="visible.length > 0">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-800/60">
|
||||||
|
<span class="text-xs font-semibold text-red-400">{{ t('escalation.escalations_panel_title') }}</span>
|
||||||
<button
|
<button
|
||||||
@click="dismissAll"
|
@click="dismissAll"
|
||||||
class="text-xs text-gray-500 hover:text-gray-300"
|
class="text-xs text-gray-500 hover:text-gray-300"
|
||||||
>{{ t('escalation.dismiss_all') }}</button>
|
>{{ t('escalation.dismiss_all') }}</button>
|
||||||
<button @click="showPanel = false" class="text-gray-500 hover:text-gray-300 text-lg leading-none">×</button>
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-60 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"
|
||||||
|
>{{ t('escalation.dismiss') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-h-80 overflow-y-auto divide-y divide-gray-800">
|
<!-- Разделитель между секциями -->
|
||||||
<div
|
<div v-if="visible.length > 0 && visibleCompleted.length > 0" class="border-t border-gray-700"></div>
|
||||||
v-for="n in visible"
|
|
||||||
:key="n.task_id"
|
<!-- Секция завершённых задач -->
|
||||||
class="px-4 py-3"
|
<div v-if="visibleCompleted.length > 0">
|
||||||
>
|
<div class="px-4 py-2 border-b border-gray-800/60">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<span class="text-xs font-semibold text-green-400">{{ t('escalation.completed_panel_title') }}</span>
|
||||||
<div class="flex-1 min-w-0">
|
</div>
|
||||||
<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>
|
<div class="max-h-60 overflow-y-auto divide-y divide-gray-800">
|
||||||
<span class="text-xs text-gray-500">·</span>
|
<div
|
||||||
<span class="text-xs text-orange-400 shrink-0">{{ n.agent_role }}</span>
|
v-for="task in visibleCompleted"
|
||||||
<span v-if="n.pipeline_step" class="text-xs text-gray-600 truncate">@ {{ n.pipeline_step }}</span>
|
:key="task.id"
|
||||||
|
class="px-4 py-3 cursor-pointer hover:bg-gray-800/40 transition-colors"
|
||||||
|
@click="router.push(`/task/${task.id}`); showPanel = false"
|
||||||
|
>
|
||||||
|
<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-green-400 shrink-0">{{ task.id }}</span>
|
||||||
|
<span class="text-xs text-gray-500">·</span>
|
||||||
|
<span class="text-xs text-gray-500 truncate">{{ task.project_name }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-200 leading-snug break-words">{{ task.title }}</p>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">{{ formatTime(task.updated_at) }}</p>
|
||||||
|
<!-- Inline revise form -->
|
||||||
|
<div
|
||||||
|
v-if="revisingTaskId === task.id"
|
||||||
|
class="mt-2 flex gap-1"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="reviseComment"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('escalation.revise_comment_placeholder')"
|
||||||
|
class="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-400"
|
||||||
|
@keyup.enter="confirmRevise(task.id)"
|
||||||
|
@keyup.escape="cancelRevise"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="confirmRevise(task.id)"
|
||||||
|
class="px-2 py-1 text-xs bg-green-900/50 text-green-400 border border-green-700 rounded hover:bg-green-900 transition-colors"
|
||||||
|
>{{ t('escalation.revise_send') }}</button>
|
||||||
|
<button
|
||||||
|
@click="cancelRevise"
|
||||||
|
class="px-2 py-1 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 transition-colors"
|
||||||
|
>{{ t('escalation.revise_cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 shrink-0" @click.stop>
|
||||||
|
<button
|
||||||
|
v-if="revisingTaskId !== task.id"
|
||||||
|
@click="startRevise(task.id)"
|
||||||
|
class="px-2 py-1 text-xs bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900/60 transition-colors"
|
||||||
|
>{{ t('escalation.revise') }}</button>
|
||||||
|
<button
|
||||||
|
@click="doneCompleted(task.id)"
|
||||||
|
class="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 transition-colors"
|
||||||
|
>{{ t('escalation.done') }}</button>
|
||||||
</div>
|
</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>
|
</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"
|
|
||||||
>{{ t('escalation.dismiss') }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,15 @@
|
||||||
"escalations": "Escalations",
|
"escalations": "Escalations",
|
||||||
"escalations_panel_title": "Escalations — action required",
|
"escalations_panel_title": "Escalations — action required",
|
||||||
"dismiss_all": "Dismiss all",
|
"dismiss_all": "Dismiss all",
|
||||||
"dismiss": "Dismiss"
|
"dismiss": "Dismiss",
|
||||||
|
"completed_tasks": "Completed",
|
||||||
|
"completed_panel_title": "Completed tasks — review",
|
||||||
|
"done": "Done",
|
||||||
|
"revise": "Revise",
|
||||||
|
"revise_comment_placeholder": "Revision comment...",
|
||||||
|
"revise_send": "Send",
|
||||||
|
"revise_cancel": "Cancel",
|
||||||
|
"revise_default_comment": "Sent for revision"
|
||||||
},
|
},
|
||||||
"liveConsole": {
|
"liveConsole": {
|
||||||
"hide_log": "▲ Скрыть лог",
|
"hide_log": "▲ Скрыть лог",
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,15 @@
|
||||||
"escalations": "Эскалации",
|
"escalations": "Эскалации",
|
||||||
"escalations_panel_title": "Эскалации — требуется решение",
|
"escalations_panel_title": "Эскалации — требуется решение",
|
||||||
"dismiss_all": "Принять все",
|
"dismiss_all": "Принять все",
|
||||||
"dismiss": "Принято"
|
"dismiss": "Принято",
|
||||||
|
"completed_tasks": "Завершено",
|
||||||
|
"completed_panel_title": "Завершённые задачи — к проверке",
|
||||||
|
"done": "Готово",
|
||||||
|
"revise": "Доработать",
|
||||||
|
"revise_comment_placeholder": "Комментарий к доработке...",
|
||||||
|
"revise_send": "Отправить",
|
||||||
|
"revise_cancel": "Отмена",
|
||||||
|
"revise_default_comment": "Отправлено на доработку"
|
||||||
},
|
},
|
||||||
"liveConsole": {
|
"liveConsole": {
|
||||||
"hide_log": "▲ Скрыть лог",
|
"hide_log": "▲ Скрыть лог",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue