kin: auto-commit after pipeline

This commit is contained in:
Gros Frumos 2026-03-17 17:39:40 +02:00
parent 248934d5d7
commit 939a30a3de
6 changed files with 796 additions and 3 deletions

View 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()
})
})

View file

@ -72,6 +72,10 @@ export interface Project {
obsidian_vault_path: string | null obsidian_vault_path: string | null
deploy_command: string | null deploy_command: string | null
test_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 created_at: string
total_tasks: number total_tasks: number
done_tasks: number done_tasks: number
@ -155,18 +159,54 @@ export interface PipelineStep {
created_at: string 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 { export interface DeployResult {
success: boolean success: boolean
exit_code: number exit_code: number
stdout: string stdout: string
stderr: string stderr: string
duration_seconds: number 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 { export interface TaskFull extends Task {
pipeline_steps: PipelineStep[] pipeline_steps: PipelineStep[]
related_decisions: Decision[] related_decisions: Decision[]
project_deploy_command: string | null 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 { export interface PendingAction {
@ -317,7 +357,7 @@ export const api = {
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }), 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 }) => 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), 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), patch<Project>(`/projects/${id}`, data),
deployProject: (projectId: string) => deployProject: (projectId: string) =>
post<DeployResult>(`/projects/${projectId}/deploy`, {}), post<DeployResult>(`/projects/${projectId}/deploy`, {}),
@ -367,4 +407,12 @@ export const api = {
deleteAttachment: (taskId: string, id: number) => deleteAttachment: (taskId: string, id: number) =>
del<void>(`/tasks/${taskId}/attachments/${id}`), del<void>(`/tasks/${taskId}/attachments/${id}`),
attachmentUrl: (id: number) => `${BASE}/attachments/${id}/file`, 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}`),
} }

View 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>

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue' import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' 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 Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue' import Modal from '../components/Modal.vue'
@ -12,7 +12,7 @@ const router = useRouter()
const project = ref<ProjectDetail | null>(null) const project = ref<ProjectDetail | null>(null)
const loading = ref(true) const loading = ref(true)
const error = ref('') 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 // Phases
const phases = ref<Phase[]>([]) 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 // Add task modal
const TASK_CATEGORIES = ['SEC', 'UI', 'API', 'INFRA', 'BIZ', 'DB', 'ARCH', 'TEST', 'PERF', 'DOCS', 'FIX', 'OBS'] const TASK_CATEGORIES = ['SEC', 'UI', 'API', 'INFRA', 'BIZ', 'DB', 'ARCH', 'TEST', 'PERF', 'DOCS', 'FIX', 'OBS']
const CATEGORY_COLORS: Record<string, string> = { const CATEGORY_COLORS: Record<string, string> = {
@ -425,12 +500,19 @@ watch(() => props.id, () => {
environments.value = [] environments.value = []
showScanBanner.value = false showScanBanner.value = false
scanTaskId.value = null scanTaskId.value = null
links.value = []
deployResult.value = null
}) })
onMounted(async () => { onMounted(async () => {
await load() await load()
await loadPhases() await loadPhases()
await loadEnvironments() await loadEnvironments()
await loadLinks()
try {
const all = await api.projects()
allProjects.value = all.map(p => ({ id: p.id, name: p.name }))
} catch {}
}) })
onUnmounted(() => { onUnmounted(() => {

View file

@ -19,6 +19,14 @@ const saveAutoTestStatus = ref<Record<string, string>>({})
const syncResults = ref<Record<string, ObsidianSyncResult | null>>({}) const syncResults = ref<Record<string, ObsidianSyncResult | null>>({})
const error = ref<string | null>(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 () => { onMounted(async () => {
try { try {
projects.value = await api.projects() projects.value = await api.projects()
@ -27,12 +35,34 @@ onMounted(async () => {
deployCommands.value[p.id] = p.deploy_command ?? '' deployCommands.value[p.id] = p.deploy_command ?? ''
testCommands.value[p.id] = p.test_command ?? '' testCommands.value[p.id] = p.test_command ?? ''
autoTestEnabled.value[p.id] = !!(p.auto_test_enabled) 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) { } catch (e) {
error.value = String(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) { async function saveVaultPath(projectId: string) {
saving.value[projectId] = true saving.value[projectId] = true
saveStatus.value[projectId] = '' saveStatus.value[projectId] = ''
@ -172,6 +202,63 @@ async function runSync(projectId: string) {
</span> </span>
</div> </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"> <div class="flex items-center gap-3 mb-3">
<label class="flex items-center gap-2 cursor-pointer select-none"> <label class="flex items-center gap-2 cursor-pointer select-none">
<input <input

View file

@ -6,6 +6,7 @@ import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue' import Modal from '../components/Modal.vue'
import AttachmentUploader from '../components/AttachmentUploader.vue' import AttachmentUploader from '../components/AttachmentUploader.vue'
import AttachmentList from '../components/AttachmentList.vue' import AttachmentList from '../components/AttachmentList.vue'
import LiveConsole from '../components/LiveConsole.vue'
const props = defineProps<{ id: string }>() const props = defineProps<{ id: string }>()
const route = useRoute() const route = useRoute()
@ -468,6 +469,14 @@ async function saveEdit() {
No pipeline steps yet. No pipeline steps yet.
</div> </div>
<!-- Live Console -->
<LiveConsole
v-if="task.pipeline_id"
:pipeline-id="task.pipeline_id"
:pipeline-status="task.status"
class="mb-6"
/>
<!-- Selected step output --> <!-- Selected step output -->
<div v-if="selectedStep" class="mb-6"> <div v-if="selectedStep" class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-2"> <h2 class="text-sm font-semibold text-gray-300 mb-2">