From 939a30a3deb9f2723737f64dd206ad155034df1e Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Tue, 17 Mar 2026 17:39:40 +0200 Subject: [PATCH] kin: auto-commit after pipeline --- .../src/__tests__/live-console.test.ts | 450 ++++++++++++++++++ web/frontend/src/api.ts | 50 +- web/frontend/src/components/LiveConsole.vue | 117 +++++ web/frontend/src/views/ProjectView.vue | 86 +++- web/frontend/src/views/SettingsView.vue | 87 ++++ web/frontend/src/views/TaskDetail.vue | 9 + 6 files changed, 796 insertions(+), 3 deletions(-) create mode 100644 web/frontend/src/__tests__/live-console.test.ts create mode 100644 web/frontend/src/components/LiveConsole.vue diff --git a/web/frontend/src/__tests__/live-console.test.ts b/web/frontend/src/__tests__/live-console.test.ts new file mode 100644 index 0000000..c05a9b4 --- /dev/null +++ b/web/frontend/src/__tests__/live-console.test.ts @@ -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 | 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() + }) +}) diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 44137c1..5a55ffa 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -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 | 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(`/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(`/projects/${id}`, data), deployProject: (projectId: string) => post(`/projects/${projectId}/deploy`, {}), @@ -367,4 +407,12 @@ export const api = { deleteAttachment: (taskId: string, id: number) => del(`/tasks/${taskId}/attachments/${id}`), attachmentUrl: (id: number) => `${BASE}/attachments/${id}/file`, + getPipelineLogs: (pipelineId: string, sinceId: number) => + get(`/pipelines/${pipelineId}/logs?since_id=${sinceId}`), + projectLinks: (projectId: string) => + get(`/projects/${projectId}/links`), + createProjectLink: (data: { from_project: string; to_project: string; link_type: string; description?: string }) => + post('/project-links', data), + deleteProjectLink: (id: number) => + del(`/project-links/${id}`), } diff --git a/web/frontend/src/components/LiveConsole.vue b/web/frontend/src/components/LiveConsole.vue new file mode 100644 index 0000000..9e67404 --- /dev/null +++ b/web/frontend/src/components/LiveConsole.vue @@ -0,0 +1,117 @@ + + + diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index c0e446b..4d88530 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -1,7 +1,7 @@