kin: auto-commit after pipeline
This commit is contained in:
parent
248934d5d7
commit
939a30a3de
6 changed files with 796 additions and 3 deletions
450
web/frontend/src/__tests__/live-console.test.ts
Normal file
450
web/frontend/src/__tests__/live-console.test.ts
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
/**
|
||||
* KIN-084: LiveConsole — панель лога в реальном времени
|
||||
*
|
||||
* Проверяет:
|
||||
* 1. Тогл видимости (по умолчанию скрыта, открывается/закрывается по клику)
|
||||
* 2. Поллинг (запуск при открытии+running, since_id, остановка, onUnmounted)
|
||||
* 3. Отображение логов (формат, цвета уровней, extra_json)
|
||||
* 4. Автоскролл (scrollTop=scrollHeight при новых логах, отключение при ручном скролле)
|
||||
* 5. Ограничение массива до 500 строк (FIFO)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import LiveConsole from '../components/LiveConsole.vue'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getPipelineLogs: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { api } from '../api'
|
||||
|
||||
function makeLog(id: number, level: 'INFO' | 'DEBUG' | 'ERROR' | 'WARN' = 'INFO', extra: Record<string, unknown> | null = null) {
|
||||
return { id, ts: `2026-03-17T10:00:0${id}`, level, message: `Message ${id}`, extra_json: extra }
|
||||
}
|
||||
|
||||
function mountConsole(pipelineStatus = 'running') {
|
||||
return mount(LiveConsole, {
|
||||
props: { pipelineId: 'pipeline-1', pipelineStatus },
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 1. Тогл видимости
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('тогл видимости', () => {
|
||||
it('панель лога скрыта по умолчанию', () => {
|
||||
const wrapper = mountConsole()
|
||||
const panel = wrapper.find('[class*="bg-gray-950"]')
|
||||
// v-show=false — элемент в DOM, но display: none
|
||||
expect(panel.element.style.display).toBe('none')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('кнопка показывает «Показать лог» при скрытой панели', () => {
|
||||
const wrapper = mountConsole()
|
||||
expect(wrapper.find('button').text()).toContain('Показать лог')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('клик по кнопке открывает панель', async () => {
|
||||
const wrapper = mountConsole()
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
const panel = wrapper.find('[class*="bg-gray-950"]')
|
||||
expect(panel.element.style.display).not.toBe('none')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('кнопка показывает «Скрыть лог» когда панель открыта', async () => {
|
||||
const wrapper = mountConsole()
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('button').text()).toContain('Скрыть лог')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('повторный клик скрывает панель', async () => {
|
||||
const wrapper = mountConsole()
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
const panel = wrapper.find('[class*="bg-gray-950"]')
|
||||
expect(panel.element.style.display).toBe('none')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 2. Поллинг
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('поллинг', () => {
|
||||
it('при открытии панели с pipelineStatus=running запускается поллинг', async () => {
|
||||
const wrapper = mountConsole('running')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('при открытии панели с pipelineStatus=in_progress запускается поллинг', async () => {
|
||||
const wrapper = mountConsole('in_progress')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('при открытии панели с pipelineStatus=done поллинг НЕ запускается', async () => {
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('since_id=0 при первом запросе', async () => {
|
||||
const wrapper = mountConsole('running')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(api.getPipelineLogs)).toHaveBeenCalledWith('pipeline-1', 0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('since_id = max(id) из предыдущего ответа для следующего запроса', async () => {
|
||||
vi.mocked(api.getPipelineLogs)
|
||||
.mockResolvedValueOnce([makeLog(3), makeLog(7), makeLog(5)] as any)
|
||||
.mockResolvedValue([])
|
||||
|
||||
const wrapper = mountConsole('running')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await flushPromises()
|
||||
|
||||
const calls = vi.mocked(api.getPipelineLogs).mock.calls
|
||||
expect(calls[0][1]).toBe(0)
|
||||
expect(calls[1][1]).toBe(7)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('поллинг тикает каждые 2 секунды', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValue([])
|
||||
|
||||
const wrapper = mountConsole('running')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const callsAfterOpen = vi.mocked(api.getPipelineLogs).mock.calls.length
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(api.getPipelineLogs).mock.calls.length).toBe(callsAfterOpen + 2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('при закрытии панели поллинг останавливается', async () => {
|
||||
const wrapper = mountConsole('running')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('onUnmounted — clearInterval вызван, vi.getTimerCount() === 0', async () => {
|
||||
const wrapper = mountConsole('running')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0)
|
||||
wrapper.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('смена pipelineStatus на done останавливает поллинг и делает финальный fetch', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValue([])
|
||||
|
||||
const wrapper = mountConsole('running')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const callsBefore = vi.mocked(api.getPipelineLogs).mock.calls.length
|
||||
await wrapper.setProps({ pipelineStatus: 'done' })
|
||||
await flushPromises()
|
||||
|
||||
// Финальный fetch
|
||||
expect(vi.mocked(api.getPipelineLogs).mock.calls.length).toBeGreaterThan(callsBefore)
|
||||
// Поллинг остановлен
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 3. Отображение логов
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('отображение логов', () => {
|
||||
it('строка лога отображается с временем и сообщением', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1)] as any)
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('2026-03-17T10:00:01')
|
||||
expect(wrapper.text()).toContain('Message 1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('строка лога отображает метку уровня [INFO]', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'INFO')] as any)
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('[INFO]')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ERROR → класс text-red-400', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'ERROR')] as any)
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const redEl = wrapper.find('.text-red-400')
|
||||
expect(redEl.exists()).toBe(true)
|
||||
expect(redEl.text()).toContain('[ERROR]')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('WARN → класс text-yellow-400', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'WARN')] as any)
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const yellowEl = wrapper.find('.text-yellow-400')
|
||||
expect(yellowEl.exists()).toBe(true)
|
||||
expect(yellowEl.text()).toContain('[WARN]')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('INFO → класс text-gray-300', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'INFO')] as any)
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const infoEl = wrapper.find('.text-gray-300')
|
||||
expect(infoEl.exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('DEBUG → класс text-gray-500', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'DEBUG')] as any)
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const debugEls = wrapper.findAll('.text-gray-500')
|
||||
// text-gray-500 применяется к метке DEBUG и к extra_json
|
||||
const hasDebug = debugEls.some(el => el.text().includes('[DEBUG]'))
|
||||
expect(hasDebug).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('extra_json отображается через JSON.stringify когда не null', async () => {
|
||||
const extra = { key: 'value', num: 42 }
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'INFO', extra)] as any)
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('"key"')
|
||||
expect(wrapper.text()).toContain('"value"')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('extra_json НЕ отображается когда null', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1, 'INFO', null)] as any)
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const pres = wrapper.findAll('pre')
|
||||
expect(pres.length).toBe(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('«Нет записей» отображается когда логов нет', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValue([])
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Нет записей')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ошибка API отображается в красном блоке', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockRejectedValueOnce(new Error('Network fail'))
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Ошибка')
|
||||
expect(wrapper.text()).toContain('Network fail')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 4. Автоскролл
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('автоскролл', () => {
|
||||
it('при добавлении логов scrollTop устанавливается в scrollHeight', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce([makeLog(1)] as any)
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const panel = wrapper.find('[class*="bg-gray-950"]').element as HTMLElement
|
||||
// В jsdom scrollHeight = 0 (контент не рендерится), поэтому scrollTop тоже 0
|
||||
// Проверяем что scrollToBottom вызывается — косвенно через scrollTop = scrollHeight
|
||||
expect(panel.scrollTop).toBe(panel.scrollHeight)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('если userScrolled=true — scrollTop НЕ меняется при новых логах', async () => {
|
||||
vi.mocked(api.getPipelineLogs)
|
||||
.mockResolvedValueOnce([makeLog(1)] as any)
|
||||
.mockResolvedValue([makeLog(2)] as any)
|
||||
|
||||
const wrapper = mountConsole('running')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const panel = wrapper.find('[class*="bg-gray-950"]').element as HTMLElement
|
||||
|
||||
// Симулируем ручной скролл вверх
|
||||
Object.defineProperty(panel, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
Object.defineProperty(panel, 'scrollHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(panel, 'clientHeight', { value: 200, writable: true, configurable: true })
|
||||
|
||||
// Триггерим событие scroll — компонент установит userScrolled=true
|
||||
await wrapper.find('[class*="bg-gray-950"]').trigger('scroll')
|
||||
|
||||
panel.scrollTop = 10 // запомним текущий scrollTop
|
||||
|
||||
// Следующий fetch добавляет лог — scrollToBottom должен быть пропущен
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await flushPromises()
|
||||
// scrollTop не сбрасывается в 0 (scrollHeight=500)
|
||||
expect(panel.scrollTop).not.toBe(500)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 5. Ограничение массива до 500 строк (FIFO)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
describe('ограничение массива до MAX_LOGS=500', () => {
|
||||
// Нулевое дополнение — уникальные строки без substring-коллизий
|
||||
function makeLogs(count: number, startId = 1) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const id = startId + i
|
||||
return {
|
||||
id,
|
||||
ts: `ts-${String(id).padStart(6, '0')}`,
|
||||
level: 'INFO' as const,
|
||||
message: `Msg-${String(id).padStart(6, '0')}`,
|
||||
extra_json: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('при 499 строках — все сохранены (N-1)', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce(makeLogs(499) as any)
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Проверяем количество строк в DOM
|
||||
const rows = wrapper.findAll('[class*="mb-1"]')
|
||||
expect(rows.length).toBe(499)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('при 500 строках — все сохранены (N)', async () => {
|
||||
vi.mocked(api.getPipelineLogs).mockResolvedValueOnce(makeLogs(500) as any)
|
||||
|
||||
const wrapper = mountConsole('done')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const rows = wrapper.findAll('[class*="mb-1"]')
|
||||
expect(rows.length).toBe(500)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('при 501 строке — обрезка до 500, самая старая удалена (N+1)', async () => {
|
||||
// Первый fetch: 500 логов
|
||||
vi.mocked(api.getPipelineLogs)
|
||||
.mockResolvedValueOnce(makeLogs(500, 1) as any)
|
||||
.mockResolvedValueOnce(makeLogs(1, 501) as any)
|
||||
.mockResolvedValue([])
|
||||
|
||||
const wrapper = mountConsole('running')
|
||||
await wrapper.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Второй fetch: +1 лог → итого 501 → обрезка до 500
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await flushPromises()
|
||||
|
||||
const rows = wrapper.findAll('[class*="mb-1"]')
|
||||
expect(rows.length).toBe(500)
|
||||
|
||||
// Первый лог (id=1, msg="Msg-000001") удалён
|
||||
const texts = rows.map(r => r.text())
|
||||
expect(texts.some(t => t.includes('Msg-000001'))).toBe(false)
|
||||
// Последний лог (id=501) присутствует
|
||||
expect(texts.some(t => t.includes('Msg-000501'))).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
|
@ -72,6 +72,10 @@ export interface Project {
|
|||
obsidian_vault_path: string | null
|
||||
deploy_command: string | null
|
||||
test_command: string | null
|
||||
deploy_host: string | null
|
||||
deploy_path: string | null
|
||||
deploy_runtime: string | null
|
||||
deploy_restart_cmd: string | null
|
||||
created_at: string
|
||||
total_tasks: number
|
||||
done_tasks: number
|
||||
|
|
@ -155,18 +159,54 @@ export interface PipelineStep {
|
|||
created_at: string
|
||||
}
|
||||
|
||||
export interface DeployStepResult {
|
||||
step: string
|
||||
stdout: string
|
||||
stderr: string
|
||||
exit_code: number
|
||||
}
|
||||
|
||||
export interface DependentDeploy {
|
||||
project_id: string
|
||||
project_name: string
|
||||
success: boolean
|
||||
results: DeployStepResult[]
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
success: boolean
|
||||
exit_code: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
duration_seconds: number
|
||||
steps?: string[]
|
||||
results?: DeployStepResult[]
|
||||
dependents_deployed?: DependentDeploy[]
|
||||
overall_success?: boolean
|
||||
}
|
||||
|
||||
export interface ProjectLink {
|
||||
id: number
|
||||
from_project: string
|
||||
to_project: string
|
||||
link_type: string
|
||||
description: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TaskFull extends Task {
|
||||
pipeline_steps: PipelineStep[]
|
||||
related_decisions: Decision[]
|
||||
project_deploy_command: string | null
|
||||
pipeline_id: string | null
|
||||
}
|
||||
|
||||
export interface PipelineLog {
|
||||
id: number
|
||||
ts: string
|
||||
level: 'INFO' | 'DEBUG' | 'ERROR' | 'WARN'
|
||||
message: string
|
||||
extra_json: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface PendingAction {
|
||||
|
|
@ -317,7 +357,7 @@ export const api = {
|
|||
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
|
||||
patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string; acceptance_criteria?: string }) =>
|
||||
patch<Task>(`/tasks/${id}`, data),
|
||||
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; auto_test_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string; test_command?: string; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string }) =>
|
||||
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; auto_test_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string; test_command?: string; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string; deploy_host?: string; deploy_path?: string; deploy_runtime?: string; deploy_restart_cmd?: string }) =>
|
||||
patch<Project>(`/projects/${id}`, data),
|
||||
deployProject: (projectId: string) =>
|
||||
post<DeployResult>(`/projects/${projectId}/deploy`, {}),
|
||||
|
|
@ -367,4 +407,12 @@ export const api = {
|
|||
deleteAttachment: (taskId: string, id: number) =>
|
||||
del<void>(`/tasks/${taskId}/attachments/${id}`),
|
||||
attachmentUrl: (id: number) => `${BASE}/attachments/${id}/file`,
|
||||
getPipelineLogs: (pipelineId: string, sinceId: number) =>
|
||||
get<PipelineLog[]>(`/pipelines/${pipelineId}/logs?since_id=${sinceId}`),
|
||||
projectLinks: (projectId: string) =>
|
||||
get<ProjectLink[]>(`/projects/${projectId}/links`),
|
||||
createProjectLink: (data: { from_project: string; to_project: string; link_type: string; description?: string }) =>
|
||||
post<ProjectLink>('/project-links', data),
|
||||
deleteProjectLink: (id: number) =>
|
||||
del<void>(`/project-links/${id}`),
|
||||
}
|
||||
|
|
|
|||
117
web/frontend/src/components/LiveConsole.vue
Normal file
117
web/frontend/src/components/LiveConsole.vue
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { api, type PipelineLog } from '../api'
|
||||
|
||||
const props = defineProps<{
|
||||
pipelineId: string
|
||||
pipelineStatus: string
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const logs = ref<PipelineLog[]>([])
|
||||
const error = ref('')
|
||||
const consoleEl = ref<HTMLElement | null>(null)
|
||||
let sinceId = 0
|
||||
let userScrolled = false
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const MAX_LOGS = 500
|
||||
|
||||
function levelClass(level: PipelineLog['level']): string {
|
||||
switch (level) {
|
||||
case 'INFO': return 'text-gray-300'
|
||||
case 'DEBUG': return 'text-gray-500'
|
||||
case 'ERROR': return 'text-red-400'
|
||||
case 'WARN': return 'text-yellow-400'
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (!consoleEl.value) return
|
||||
const { scrollTop, clientHeight, scrollHeight } = consoleEl.value
|
||||
userScrolled = scrollTop + clientHeight < scrollHeight - 50
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!consoleEl.value || userScrolled) return
|
||||
consoleEl.value.scrollTop = consoleEl.value.scrollHeight
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
try {
|
||||
const newLogs = await api.getPipelineLogs(props.pipelineId, sinceId)
|
||||
if (!newLogs.length) return
|
||||
sinceId = Math.max(...newLogs.map(l => l.id))
|
||||
logs.value = [...logs.value, ...newLogs].slice(-MAX_LOGS)
|
||||
// Scroll after DOM update
|
||||
setTimeout(scrollToBottom, 0)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (timer) return
|
||||
timer = setInterval(async () => {
|
||||
await fetchLogs()
|
||||
if (props.pipelineStatus !== 'running' && props.pipelineStatus !== 'in_progress') {
|
||||
stopPolling()
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (timer) { clearInterval(timer); timer = null }
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
visible.value = !visible.value
|
||||
if (visible.value) {
|
||||
// Reset on open
|
||||
userScrolled = false
|
||||
await fetchLogs()
|
||||
if (props.pipelineStatus === 'running' || props.pipelineStatus === 'in_progress') {
|
||||
startPolling()
|
||||
}
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
// When status changes while panel is open — do final fetch and stop
|
||||
watch(() => props.pipelineStatus, async (newStatus) => {
|
||||
if (!visible.value) return
|
||||
if (newStatus !== 'running' && newStatus !== 'in_progress') {
|
||||
stopPolling()
|
||||
await fetchLogs()
|
||||
} else {
|
||||
startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="toggle"
|
||||
class="text-xs text-gray-500 hover:text-gray-300 border border-gray-800 rounded px-3 py-1.5 bg-gray-900/50 hover:bg-gray-900 transition-colors"
|
||||
>
|
||||
{{ visible ? '▲ Скрыть лог' : '▼ Показать лог' }}
|
||||
</button>
|
||||
|
||||
<div v-show="visible" class="mt-2 bg-gray-950 border border-gray-800 rounded-lg p-4 font-mono text-xs max-h-[400px] overflow-y-auto" ref="consoleEl" @scroll="onScroll">
|
||||
<div v-if="!logs.length && !error" class="text-gray-600">Нет записей...</div>
|
||||
<div v-if="error" class="text-red-400">Ошибка: {{ error }}</div>
|
||||
<div v-for="log in logs" :key="log.id" class="mb-1">
|
||||
<span class="text-gray-600">{{ log.ts }}</span>
|
||||
<span :class="[levelClass(log.level), 'ml-2 font-semibold']">[{{ log.level }}]</span>
|
||||
<span :class="[levelClass(log.level), 'ml-2']">{{ log.message }}</span>
|
||||
<pre v-if="log.extra_json" class="mt-0.5 ml-4 text-gray-500 whitespace-pre-wrap">{{ JSON.stringify(log.extra_json, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment } from '../api'
|
||||
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ const router = useRouter()
|
|||
const project = ref<ProjectDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments'>('tasks')
|
||||
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links'>('tasks')
|
||||
|
||||
// Phases
|
||||
const phases = ref<Phase[]>([])
|
||||
|
|
@ -369,6 +369,81 @@ async function deleteEnv(envId: number) {
|
|||
}
|
||||
}
|
||||
|
||||
// Deploy
|
||||
const deploying = ref(false)
|
||||
const deployResult = ref<DeployResult | null>(null)
|
||||
|
||||
const hasDeployConfig = computed(() => {
|
||||
if (!project.value) return false
|
||||
return !!(project.value.deploy_host && project.value.deploy_path && project.value.deploy_runtime) || !!project.value.deploy_command
|
||||
})
|
||||
|
||||
async function runDeploy() {
|
||||
deploying.value = true
|
||||
deployResult.value = null
|
||||
try {
|
||||
deployResult.value = await api.deployProject(props.id)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
deploying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Project Links
|
||||
const links = ref<ProjectLink[]>([])
|
||||
const linksLoading = ref(false)
|
||||
const linksError = ref('')
|
||||
const showAddLink = ref(false)
|
||||
const linkForm = ref({ to_project: '', link_type: 'depends_on', description: '' })
|
||||
const linkFormError = ref('')
|
||||
const linkSaving = ref(false)
|
||||
|
||||
async function loadLinks() {
|
||||
linksLoading.value = true
|
||||
linksError.value = ''
|
||||
try {
|
||||
links.value = await api.projectLinks(props.id)
|
||||
} catch (e: any) {
|
||||
linksError.value = e.message
|
||||
} finally {
|
||||
linksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addLink() {
|
||||
linkFormError.value = ''
|
||||
if (!linkForm.value.to_project) { linkFormError.value = 'Выберите проект'; return }
|
||||
linkSaving.value = true
|
||||
try {
|
||||
await api.createProjectLink({
|
||||
from_project: props.id,
|
||||
to_project: linkForm.value.to_project,
|
||||
link_type: linkForm.value.link_type,
|
||||
description: linkForm.value.description || undefined,
|
||||
})
|
||||
showAddLink.value = false
|
||||
linkForm.value = { to_project: '', link_type: 'depends_on', description: '' }
|
||||
await loadLinks()
|
||||
} catch (e: any) {
|
||||
linkFormError.value = e.message
|
||||
} finally {
|
||||
linkSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLink(id: number) {
|
||||
if (!confirm('Удалить связь?')) return
|
||||
try {
|
||||
await api.deleteProjectLink(id)
|
||||
await loadLinks()
|
||||
} catch (e: any) {
|
||||
linksError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
const allProjects = ref<{ id: string; name: string }[]>([])
|
||||
|
||||
// Add task modal
|
||||
const TASK_CATEGORIES = ['SEC', 'UI', 'API', 'INFRA', 'BIZ', 'DB', 'ARCH', 'TEST', 'PERF', 'DOCS', 'FIX', 'OBS']
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
|
|
@ -425,12 +500,19 @@ watch(() => props.id, () => {
|
|||
environments.value = []
|
||||
showScanBanner.value = false
|
||||
scanTaskId.value = null
|
||||
links.value = []
|
||||
deployResult.value = null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
await loadPhases()
|
||||
await loadEnvironments()
|
||||
await loadLinks()
|
||||
try {
|
||||
const all = await api.projects()
|
||||
allProjects.value = all.map(p => ({ id: p.id, name: p.name }))
|
||||
} catch {}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ const saveAutoTestStatus = ref<Record<string, string>>({})
|
|||
const syncResults = ref<Record<string, ObsidianSyncResult | null>>({})
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Deploy config
|
||||
const deployHosts = ref<Record<string, string>>({})
|
||||
const deployPaths = ref<Record<string, string>>({})
|
||||
const deployRuntimes = ref<Record<string, string>>({})
|
||||
const deployRestartCmds = ref<Record<string, string>>({})
|
||||
const savingDeployConfig = ref<Record<string, boolean>>({})
|
||||
const saveDeployConfigStatus = ref<Record<string, string>>({})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
projects.value = await api.projects()
|
||||
|
|
@ -27,12 +35,34 @@ onMounted(async () => {
|
|||
deployCommands.value[p.id] = p.deploy_command ?? ''
|
||||
testCommands.value[p.id] = p.test_command ?? ''
|
||||
autoTestEnabled.value[p.id] = !!(p.auto_test_enabled)
|
||||
deployHosts.value[p.id] = p.deploy_host ?? ''
|
||||
deployPaths.value[p.id] = p.deploy_path ?? ''
|
||||
deployRuntimes.value[p.id] = p.deploy_runtime ?? ''
|
||||
deployRestartCmds.value[p.id] = p.deploy_restart_cmd ?? ''
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = String(e)
|
||||
}
|
||||
})
|
||||
|
||||
async function saveDeployConfig(projectId: string) {
|
||||
savingDeployConfig.value[projectId] = true
|
||||
saveDeployConfigStatus.value[projectId] = ''
|
||||
try {
|
||||
await api.patchProject(projectId, {
|
||||
deploy_host: deployHosts.value[projectId] || undefined,
|
||||
deploy_path: deployPaths.value[projectId] || undefined,
|
||||
deploy_runtime: deployRuntimes.value[projectId] || undefined,
|
||||
deploy_restart_cmd: deployRestartCmds.value[projectId] || undefined,
|
||||
})
|
||||
saveDeployConfigStatus.value[projectId] = 'Saved'
|
||||
} catch (e) {
|
||||
saveDeployConfigStatus.value[projectId] = `Error: ${e}`
|
||||
} finally {
|
||||
savingDeployConfig.value[projectId] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveVaultPath(projectId: string) {
|
||||
saving.value[projectId] = true
|
||||
saveStatus.value[projectId] = ''
|
||||
|
|
@ -172,6 +202,63 @@ async function runSync(projectId: string) {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Deploy Config -->
|
||||
<div class="mb-2 pt-2 border-t border-gray-800">
|
||||
<p class="text-xs font-semibold text-gray-400 mb-2">Deploy Config</p>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Server host</label>
|
||||
<input
|
||||
v-model="deployHosts[project.id]"
|
||||
type="text"
|
||||
placeholder="server host (e.g. vdp-prod)"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Project path on server</label>
|
||||
<input
|
||||
v-model="deployPaths[project.id]"
|
||||
type="text"
|
||||
placeholder="/srv/myproject"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Runtime</label>
|
||||
<select
|
||||
v-model="deployRuntimes[project.id]"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-gray-500"
|
||||
>
|
||||
<option value="">— выберите runtime —</option>
|
||||
<option value="docker">docker</option>
|
||||
<option value="node">node</option>
|
||||
<option value="python">python</option>
|
||||
<option value="static">static</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Restart command (optional override)</label>
|
||||
<input
|
||||
v-model="deployRestartCmds[project.id]"
|
||||
type="text"
|
||||
placeholder="optional override command"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap mb-3">
|
||||
<button
|
||||
@click="saveDeployConfig(project.id)"
|
||||
:disabled="savingDeployConfig[project.id]"
|
||||
class="px-3 py-1.5 text-sm bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50"
|
||||
>
|
||||
{{ savingDeployConfig[project.id] ? 'Saving…' : 'Save Deploy Config' }}
|
||||
</button>
|
||||
<span v-if="saveDeployConfigStatus[project.id]" class="text-xs" :class="saveDeployConfigStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||
{{ saveDeployConfigStatus[project.id] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import Badge from '../components/Badge.vue'
|
|||
import Modal from '../components/Modal.vue'
|
||||
import AttachmentUploader from '../components/AttachmentUploader.vue'
|
||||
import AttachmentList from '../components/AttachmentList.vue'
|
||||
import LiveConsole from '../components/LiveConsole.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const route = useRoute()
|
||||
|
|
@ -468,6 +469,14 @@ async function saveEdit() {
|
|||
No pipeline steps yet.
|
||||
</div>
|
||||
|
||||
<!-- Live Console -->
|
||||
<LiveConsole
|
||||
v-if="task.pipeline_id"
|
||||
:pipeline-id="task.pipeline_id"
|
||||
:pipeline-status="task.status"
|
||||
class="mb-6"
|
||||
/>
|
||||
|
||||
<!-- Selected step output -->
|
||||
<div v-if="selectedStep" class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-2">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue