kin: KIN-BIZ-006 Проверить промпт sysadmin.md на поддержку сценария env_scan

This commit is contained in:
Gros Frumos 2026-03-16 19:26:51 +02:00
parent 531275e4ce
commit a58578bb9d
14 changed files with 1619 additions and 13 deletions

View file

@ -226,6 +226,19 @@ export interface AuditResult {
error?: string
}
export interface ProjectEnvironment {
id: number
project_id: string
name: string
host: string
port: number
username: string
auth_type: string
is_installed: number
created_at: string
updated_at: string
}
export interface EscalationNotification {
task_id: string
project_id: string
@ -236,6 +249,26 @@ export interface EscalationNotification {
telegram_sent: boolean
}
export interface ChatMessage {
id: number
project_id: string
role: 'user' | 'assistant'
content: string
message_type: string
task_id: string | null
created_at: string
task_stub?: {
id: string
title: string
status: string
} | null
}
export interface ChatSendResult {
user_message: ChatMessage
assistant_message: ChatMessage
}
export const api = {
projects: () => get<Project[]>('/projects'),
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
@ -291,4 +324,18 @@ export const api = {
post<{ phase: Phase; new_task: Task }>(`/phases/${phaseId}/revise`, { comment }),
startPhase: (projectId: string) =>
post<{ status: string; phase_id: number; task_id: string }>(`/projects/${projectId}/phases/start`, {}),
environments: (projectId: string) =>
get<ProjectEnvironment[]>(`/projects/${projectId}/environments`),
createEnvironment: (projectId: string, data: { name: string; host: string; port?: number; username: string; auth_type?: string; auth_value?: string; is_installed?: boolean }) =>
post<ProjectEnvironment & { scan_task_id?: string }>(`/projects/${projectId}/environments`, data),
updateEnvironment: (projectId: string, envId: number, data: { name?: string; host?: string; port?: number; username?: string; auth_type?: string; auth_value?: string; is_installed?: boolean }) =>
patch<ProjectEnvironment & { scan_task_id?: string }>(`/projects/${projectId}/environments/${envId}`, data),
deleteEnvironment: (projectId: string, envId: number) =>
del<void>(`/projects/${projectId}/environments/${envId}`),
scanEnvironment: (projectId: string, envId: number) =>
post<{ status: string; task_id: string }>(`/projects/${projectId}/environments/${envId}/scan`, {}),
chatHistory: (projectId: string, limit = 50) =>
get<ChatMessage[]>(`/projects/${projectId}/chat?limit=${limit}`),
sendChatMessage: (projectId: string, content: string) =>
post<ChatSendResult>(`/projects/${projectId}/chat`, { content }),
}

View file

@ -6,6 +6,7 @@ import Dashboard from './views/Dashboard.vue'
import ProjectView from './views/ProjectView.vue'
import TaskDetail from './views/TaskDetail.vue'
import SettingsView from './views/SettingsView.vue'
import ChatView from './views/ChatView.vue'
const router = createRouter({
history: createWebHistory(),
@ -14,6 +15,7 @@ const router = createRouter({
{ path: '/project/:id', component: ProjectView, props: true },
{ path: '/task/:id', component: TaskDetail, props: true },
{ path: '/settings', component: SettingsView },
{ path: '/chat/:projectId', component: ChatView, props: true },
],
})

View file

@ -0,0 +1,215 @@
<script setup lang="ts">
import { ref, watch, nextTick, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { api, ApiError, type ChatMessage } from '../api'
import Badge from '../components/Badge.vue'
const props = defineProps<{ projectId: string }>()
const router = useRouter()
const messages = ref<ChatMessage[]>([])
const input = ref('')
const sending = ref(false)
const loading = ref(true)
const error = ref('')
const projectName = ref('')
const messagesEl = ref<HTMLElement | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function hasRunningTasks(msgs: ChatMessage[]) {
return msgs.some(
m => m.task_stub?.status === 'in_progress' || m.task_stub?.status === 'pending'
)
}
function checkAndPoll() {
stopPoll()
if (!hasRunningTasks(messages.value)) return
pollTimer = setInterval(async () => {
try {
const updated = await api.chatHistory(props.projectId)
messages.value = updated
if (!hasRunningTasks(updated)) stopPoll()
} catch {}
}, 3000)
}
async function load() {
stopPoll()
loading.value = true
error.value = ''
try {
const [msgs, project] = await Promise.all([
api.chatHistory(props.projectId),
api.project(props.projectId),
])
messages.value = msgs
projectName.value = project.name
} catch (e: any) {
if (e instanceof ApiError && e.message.includes('not found')) {
router.push('/')
return
}
error.value = e.message
} finally {
loading.value = false
}
await nextTick()
scrollToBottom()
checkAndPoll()
}
watch(() => props.projectId, () => {
messages.value = []
input.value = ''
error.value = ''
projectName.value = ''
loading.value = true
load()
}, { immediate: true })
onUnmounted(stopPoll)
async function send() {
const text = input.value.trim()
if (!text || sending.value) return
sending.value = true
error.value = ''
try {
const result = await api.sendChatMessage(props.projectId, text)
input.value = ''
messages.value.push(result.user_message, result.assistant_message)
await nextTick()
scrollToBottom()
checkAndPoll()
} catch (e: any) {
error.value = e.message
} finally {
sending.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
function scrollToBottom() {
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
}
function taskStatusColor(status: string): string {
const map: Record<string, string> = {
done: 'green',
in_progress: 'blue',
review: 'yellow',
blocked: 'red',
pending: 'gray',
cancelled: 'gray',
}
return map[status] ?? 'gray'
}
function formatTime(dt: string) {
return new Date(dt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
}
</script>
<template>
<div class="flex flex-col h-[calc(100vh-112px)]">
<!-- Header -->
<div class="flex items-center gap-3 pb-4 border-b border-gray-800">
<router-link
:to="`/project/${projectId}`"
class="text-gray-400 hover:text-gray-200 text-sm no-underline"
> Проект</router-link>
<span class="text-gray-600">|</span>
<h1 class="text-base font-semibold text-gray-100">
{{ projectName || projectId }}
</h1>
<span class="text-xs text-gray-500 ml-1"> чат</span>
</div>
<!-- Error -->
<div v-if="error" class="mt-3 text-sm text-red-400 bg-red-900/20 border border-red-800 rounded px-3 py-2">
{{ error }}
</div>
<!-- Loading -->
<div v-if="loading" class="flex-1 flex items-center justify-center">
<span class="text-gray-500 text-sm">Загрузка...</span>
</div>
<!-- Messages -->
<div
v-else
ref="messagesEl"
class="flex-1 overflow-y-auto py-4 flex flex-col gap-3 min-h-0"
>
<div v-if="messages.length === 0" class="text-center text-gray-500 text-sm mt-8">
Опишите задачу или спросите о статусе проекта
</div>
<div
v-for="msg in messages"
:key="msg.id"
class="flex"
:class="msg.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[70%] rounded-2xl px-4 py-2.5"
:class="msg.role === 'user'
? 'bg-indigo-900/60 border border-indigo-700/50 text-gray-100 rounded-br-sm'
: 'bg-gray-800/70 border border-gray-700/50 text-gray-200 rounded-bl-sm'"
>
<p class="text-sm whitespace-pre-wrap break-words">{{ msg.content }}</p>
<!-- Task stub for task_created messages -->
<div
v-if="msg.message_type === 'task_created' && msg.task_stub"
class="mt-2 pt-2 border-t border-gray-700/40 flex items-center gap-2"
>
<router-link
:to="`/task/${msg.task_stub.id}`"
class="text-xs text-indigo-400 hover:text-indigo-300 no-underline font-mono"
>{{ msg.task_stub.id }}</router-link>
<Badge :color="taskStatusColor(msg.task_stub.status)" :text="msg.task_stub.status" />
</div>
<p class="text-xs mt-1.5 text-gray-500">{{ formatTime(msg.created_at) }}</p>
</div>
</div>
</div>
<!-- Input -->
<div class="pt-3 border-t border-gray-800 flex gap-2 items-end">
<textarea
v-model="input"
:disabled="sending || loading"
placeholder="Опишите задачу или вопрос... (Enter — отправить, Shift+Enter — перенос)"
rows="2"
class="flex-1 bg-gray-800/60 border border-gray-700 rounded-xl px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 resize-none focus:outline-none focus:border-indigo-600 disabled:opacity-50"
@keydown="onKeydown"
/>
<button
:disabled="sending || loading || !input.trim()"
class="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm rounded-xl font-medium transition-colors"
@click="send"
>
{{ sending ? '...' : 'Отправить' }}
</button>
</div>
</div>
</template>

View file

@ -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 } from '../api'
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment } 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'>('tasks')
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments'>('tasks')
// Phases
const phases = ref<Phase[]>([])
@ -246,6 +246,83 @@ async function applyAudit() {
}
}
// Environments
const environments = ref<ProjectEnvironment[]>([])
const envsLoading = ref(false)
const envsError = ref('')
const showEnvModal = ref(false)
const editingEnv = ref<ProjectEnvironment | null>(null)
const envForm = ref({ name: 'prod', host: '', port: 22, username: '', auth_type: 'password', auth_value: '', is_installed: false })
const envFormError = ref('')
const envSaving = ref(false)
const scanTaskId = ref<string | null>(null)
const showScanBanner = ref(false)
async function loadEnvironments() {
envsLoading.value = true
envsError.value = ''
try {
environments.value = await api.environments(props.id)
} catch (e: any) {
envsError.value = e.message
} finally {
envsLoading.value = false
}
}
function openEnvModal(env?: ProjectEnvironment) {
editingEnv.value = env || null
if (env) {
envForm.value = { name: env.name, host: env.host, port: env.port, username: env.username, auth_type: env.auth_type, auth_value: '', is_installed: !!env.is_installed }
} else {
envForm.value = { name: 'prod', host: '', port: 22, username: '', auth_type: 'password', auth_value: '', is_installed: false }
}
envFormError.value = ''
showEnvModal.value = true
}
async function submitEnv() {
envFormError.value = ''
envSaving.value = true
try {
const payload = {
name: envForm.value.name,
host: envForm.value.host,
port: envForm.value.port,
username: envForm.value.username,
auth_type: envForm.value.auth_type,
auth_value: envForm.value.auth_value || undefined,
is_installed: envForm.value.is_installed,
}
let res: ProjectEnvironment & { scan_task_id?: string }
if (editingEnv.value) {
res = await api.updateEnvironment(props.id, editingEnv.value.id, payload)
} else {
res = await api.createEnvironment(props.id, payload)
}
showEnvModal.value = false
await loadEnvironments()
if (res.scan_task_id) {
scanTaskId.value = res.scan_task_id
showScanBanner.value = true
}
} catch (e: any) {
envFormError.value = e.message
} finally {
envSaving.value = false
}
}
async function deleteEnv(envId: number) {
if (!confirm('Удалить среду?')) return
try {
await api.deleteEnvironment(props.id, envId)
await loadEnvironments()
} catch (e: any) {
envsError.value = e.message
}
}
// Add task modal
const TASK_CATEGORIES = ['SEC', 'UI', 'API', 'INFRA', 'BIZ', 'DB', 'ARCH', 'TEST', 'PERF', 'DOCS', 'FIX', 'OBS']
const CATEGORY_COLORS: Record<string, string> = {
@ -280,6 +357,9 @@ watch(selectedStatuses, (val) => {
watch(() => props.id, () => {
taskSearch.value = ''
environments.value = []
showScanBanner.value = false
scanTaskId.value = null
})
onMounted(async () => {
@ -287,6 +367,7 @@ onMounted(async () => {
loadMode()
loadAutocommit()
await loadPhases()
await loadEnvironments()
})
onUnmounted(() => {
@ -384,16 +465,25 @@ async function addTask() {
}
}
const runningTaskId = ref<string | null>(null)
async function runTask(taskId: string, event: Event) {
event.preventDefault()
event.stopPropagation()
if (!confirm(`Run pipeline for ${taskId}?`)) return
runningTaskId.value = taskId
try {
await api.runTask(taskId)
await load()
if (activeTab.value === 'kanban') checkAndPollKanban()
} catch (e: any) {
error.value = e.message
if (e instanceof ApiError && e.code === 'task_already_running') {
error.value = 'Pipeline уже запущен'
} else {
error.value = e.message
}
} finally {
runningTaskId.value = null
}
}
@ -518,6 +608,8 @@ async function addDecision() {
<div class="mb-6">
<div class="flex items-center gap-2 mb-1">
<router-link to="/" class="text-gray-600 hover:text-gray-400 text-sm no-underline">&larr; back</router-link>
<span class="text-gray-700">|</span>
<router-link :to="`/chat/${project.id}`" class="text-indigo-500 hover:text-indigo-400 text-sm no-underline">Чат</router-link>
</div>
<div class="flex items-center gap-3 mb-2">
<h1 class="text-xl font-bold text-gray-100">{{ project.id }}</h1>
@ -545,18 +637,19 @@ async function addDecision() {
<!-- Tabs -->
<div class="flex gap-1 mb-4 border-b border-gray-800">
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban'] as const)" :key="tab"
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban', 'environments'] as const)" :key="tab"
@click="activeTab = tab"
class="px-4 py-2 text-sm border-b-2 transition-colors"
:class="activeTab === tab
? 'text-gray-200 border-blue-500'
: 'text-gray-500 border-transparent hover:text-gray-300'">
{{ tab === 'kanban' ? 'Kanban' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
{{ tab === 'kanban' ? 'Kanban' : tab === 'environments' ? 'Среды' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
<span class="text-xs text-gray-600 ml-1">
{{ tab === 'tasks' ? project.tasks.length
: tab === 'phases' ? phases.length
: tab === 'decisions' ? project.decisions.length
: tab === 'modules' ? project.modules.length
: tab === 'environments' ? environments.length
: project.tasks.length }}
</span>
</button>
@ -697,8 +790,12 @@ async function addDecision() {
</select>
<button v-if="t.status === 'pending'"
@click="runTask(t.id, $event)"
class="px-2 py-0.5 bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 text-[10px]"
title="Run pipeline">&#9654;</button>
:disabled="runningTaskId === t.id"
class="px-2 py-0.5 bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 text-[10px] disabled:opacity-50"
title="Run pipeline">
<span v-if="runningTaskId === t.id" class="inline-block w-2 h-2 border border-blue-400 border-t-transparent rounded-full animate-spin"></span>
<span v-else>&#9654;</span>
</button>
<span v-if="t.status === 'in_progress'"
class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" title="Running"></span>
</div>
@ -939,6 +1036,61 @@ async function addDecision() {
</div>
</div>
<!-- Environments Tab -->
<div v-if="activeTab === 'environments'">
<!-- Scan started banner -->
<div v-if="showScanBanner" class="mb-4 px-4 py-3 border border-blue-700 bg-blue-950/30 rounded flex items-start justify-between gap-2">
<div>
<p class="text-sm font-semibold text-blue-300">&#128269; Запускаем сканирование среды...</p>
<p class="text-xs text-blue-200/70 mt-1">Создана задача сисадмина:
<router-link v-if="scanTaskId" :to="`/task/${scanTaskId}`" class="text-blue-400 hover:text-blue-300 no-underline">{{ scanTaskId }}</router-link>
</p>
<p class="text-xs text-gray-500 mt-1">Агент опишет среду, установленное ПО и настроенный git. При нехватке данных эскалация к вам.</p>
</div>
<button @click="showScanBanner = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0"></button>
</div>
<div class="flex items-center justify-between mb-3">
<span class="text-xs text-gray-500">Серверные окружения проекта</span>
<button @click="openEnvModal()"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Среда
</button>
</div>
<p v-if="envsLoading" class="text-gray-500 text-sm">Загрузка...</p>
<p v-else-if="envsError" class="text-red-400 text-sm">{{ envsError }}</p>
<div v-else-if="environments.length === 0" class="text-gray-600 text-sm">Нет сред. Добавьте сервер для развёртывания.</div>
<div v-else class="space-y-2">
<div v-for="env in environments" :key="env.id"
class="px-4 py-3 border border-gray-800 rounded hover:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-200">{{ env.name }}</span>
<span class="px-1.5 py-0.5 text-[10px] rounded border"
:class="env.is_installed ? 'bg-green-900/30 text-green-400 border-green-800' : 'bg-gray-800 text-gray-500 border-gray-700'">
{{ env.is_installed ? '&#x2713; установлен' : 'не установлен' }}
</span>
<span class="px-1.5 py-0.5 text-[10px] bg-gray-800 text-gray-500 border border-gray-700 rounded">{{ env.auth_type }}</span>
</div>
<div class="flex items-center gap-2">
<button @click="openEnvModal(env)" title="Редактировать"
class="px-2 py-0.5 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 hover:text-gray-200">
</button>
<button @click="deleteEnv(env.id)" title="Удалить"
class="px-2 py-0.5 text-xs bg-gray-800 text-red-500 border border-gray-700 rounded hover:bg-red-950/30 hover:border-red-800">
</button>
</div>
</div>
<div class="mt-1 text-xs text-gray-500 flex gap-3 flex-wrap">
<span><span class="text-gray-600">host:</span> <span class="text-orange-400">{{ env.username }}@{{ env.host }}:{{ env.port }}</span></span>
</div>
</div>
</div>
</div>
<!-- Add Task Modal -->
<Modal v-if="showAddTask" title="Add Task" @close="showAddTask = false">
<form @submit.prevent="addTask" class="space-y-3">
@ -1000,6 +1152,72 @@ async function addDecision() {
</form>
</Modal>
<!-- Environment Modal -->
<Modal v-if="showEnvModal" :title="editingEnv ? 'Редактировать среду' : 'Добавить среду'" @close="showEnvModal = false">
<form @submit.prevent="submitEnv" class="space-y-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Название</label>
<select v-model="envForm.name"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
<option value="prod">prod</option>
<option value="dev">dev</option>
<option value="staging">staging</option>
</select>
</div>
<div class="flex gap-2">
<div class="flex-1">
<label class="block text-xs text-gray-500 mb-1">Host (IP или домен)</label>
<input v-model="envForm.host" placeholder="10.0.0.1" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</div>
<div class="w-24">
<label class="block text-xs text-gray-500 mb-1">Port</label>
<input v-model.number="envForm.port" type="number" min="1" max="65535"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200" />
</div>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Login</label>
<input v-model="envForm.username" placeholder="root" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-2">Тип авторизации</label>
<div class="flex gap-4">
<label class="flex items-center gap-1.5 text-sm text-gray-300 cursor-pointer">
<input type="radio" v-model="envForm.auth_type" value="password" class="accent-blue-500" />
Пароль
</label>
<label class="flex items-center gap-1.5 text-sm text-gray-300 cursor-pointer">
<input type="radio" v-model="envForm.auth_type" value="key" class="accent-blue-500" />
SSH ключ
</label>
</div>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">{{ envForm.auth_type === 'key' ? 'SSH ключ (private key)' : 'Пароль' }}</label>
<textarea v-if="envForm.auth_type === 'key'" v-model="envForm.auth_value" rows="4"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;..."
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-xs text-gray-200 placeholder-gray-600 resize-y font-mono"></textarea>
<input v-else v-model="envForm.auth_value" type="password"
:placeholder="editingEnv ? 'Оставьте пустым, чтобы не менять' : 'Пароль'"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</div>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" v-model="envForm.is_installed" class="accent-blue-500" />
<span class="text-sm text-gray-300">&#9745; Проект уже установлен на сервере</span>
</label>
<div v-if="envForm.is_installed" class="px-3 py-2 border border-blue-800 bg-blue-950/20 rounded text-xs text-blue-300">
После сохранения будет запущен агент-сисадмин для сканирования среды.
</div>
<p v-if="envFormError" class="text-red-400 text-xs">{{ envFormError }}</p>
<button type="submit" :disabled="envSaving"
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900 disabled:opacity-50">
{{ envSaving ? 'Сохраняем...' : editingEnv ? 'Сохранить' : 'Добавить' }}
</button>
</form>
</Modal>
<!-- Audit Modal -->
<Modal v-if="showAuditModal && auditResult" title="Backlog Audit Results" @close="showAuditModal = false">
<div v-if="!auditResult.success" class="text-red-400 text-sm">

View file

@ -141,6 +141,7 @@ async function runSync(projectId: string) {
<div v-for="err in syncResults[project.id]!.errors" :key="err" class="text-red-400">{{ err }}</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -15,6 +15,7 @@ const error = ref('')
const claudeLoginError = ref(false)
const selectedStep = ref<PipelineStep | null>(null)
const polling = ref(false)
const pipelineStarting = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
// Approve modal
@ -208,6 +209,7 @@ async function revise() {
async function runPipeline() {
claudeLoginError.value = false
pipelineStarting.value = true
try {
await api.runTask(props.id)
startPolling()
@ -215,9 +217,13 @@ async function runPipeline() {
} catch (e: any) {
if (e instanceof ApiError && e.code === 'claude_auth_required') {
claudeLoginError.value = true
} else if (e instanceof ApiError && e.code === 'task_already_running') {
error.value = 'Pipeline уже запущен'
} else {
error.value = e.message
}
} finally {
pipelineStarting.value = false
}
}
@ -506,10 +512,10 @@ async function saveEdit() {
</button>
<button v-if="task.status === 'pending' || task.status === 'blocked'"
@click="runPipeline"
:disabled="polling"
:disabled="polling || pipelineStarting"
class="px-4 py-2 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
<span v-if="polling" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ polling ? 'Pipeline running...' : '&#9654; Run Pipeline' }}
<span v-if="polling || pipelineStarting" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ (polling || pipelineStarting) ? 'Pipeline running...' : '&#9654; Run Pipeline' }}
</button>
<button v-if="isManualEscalation && task.status !== 'done' && task.status !== 'cancelled'"
@click="resolveManually"