kin: KIN-089 При попытке добавить креды прод сервера для проекта corelock вылетает 500 Internal Server Error
This commit is contained in:
parent
e80e50ba0c
commit
4a65d90218
13 changed files with 1215 additions and 4 deletions
109
web/api.py
109
web/api.py
|
|
@ -4,6 +4,7 @@ Run: uvicorn web.api:app --reload --port 8420
|
|||
"""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -12,7 +13,7 @@ from pathlib import Path
|
|||
# Ensure project root on sys.path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi import FastAPI, File, HTTPException, Query, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
|
@ -1321,6 +1322,112 @@ def get_notifications(project_id: str | None = None):
|
|||
return notifications
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachments (KIN-090)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def _attachment_dir(project_path: Path, task_id: str) -> Path:
|
||||
"""Return (and create) {project_path}/.kin/attachments/{task_id}/."""
|
||||
d = project_path / ".kin" / "attachments" / task_id
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/attachments", status_code=201)
|
||||
async def upload_attachment(task_id: str, file: UploadFile = File(...)):
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
if not t:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
p = models.get_project(conn, t["project_id"])
|
||||
if not p or not p.get("path"):
|
||||
conn.close()
|
||||
raise HTTPException(400, "Attachments not supported for operations projects")
|
||||
|
||||
# Sanitize filename: strip directory components
|
||||
safe_name = Path(file.filename or "upload").name
|
||||
if not safe_name:
|
||||
conn.close()
|
||||
raise HTTPException(400, "Invalid filename")
|
||||
|
||||
att_dir = _attachment_dir(Path(p["path"]), task_id)
|
||||
dest = att_dir / safe_name
|
||||
|
||||
# Path traversal guard
|
||||
if not dest.is_relative_to(att_dir):
|
||||
conn.close()
|
||||
raise HTTPException(400, "Invalid filename")
|
||||
|
||||
# Read with size limit
|
||||
content = await file.read(_MAX_ATTACHMENT_SIZE + 1)
|
||||
if len(content) > _MAX_ATTACHMENT_SIZE:
|
||||
conn.close()
|
||||
raise HTTPException(413, f"File too large. Maximum size is {_MAX_ATTACHMENT_SIZE // (1024*1024)} MB")
|
||||
|
||||
dest.write_bytes(content)
|
||||
|
||||
mime_type = mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
|
||||
attachment = models.create_attachment(
|
||||
conn, task_id,
|
||||
filename=safe_name,
|
||||
path=str(dest),
|
||||
mime_type=mime_type,
|
||||
size=len(content),
|
||||
)
|
||||
conn.close()
|
||||
return JSONResponse(attachment, status_code=201)
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}/attachments")
|
||||
def list_task_attachments(task_id: str):
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
if not t:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
attachments = models.list_attachments(conn, task_id)
|
||||
conn.close()
|
||||
return attachments
|
||||
|
||||
|
||||
@app.delete("/api/tasks/{task_id}/attachments/{attachment_id}", status_code=204)
|
||||
def delete_task_attachment(task_id: str, attachment_id: int):
|
||||
conn = get_conn()
|
||||
att = models.get_attachment(conn, attachment_id)
|
||||
if not att or att["task_id"] != task_id:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Attachment #{attachment_id} not found")
|
||||
# Delete file from disk
|
||||
try:
|
||||
Path(att["path"]).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
models.delete_attachment(conn, attachment_id)
|
||||
conn.close()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.get("/api/attachments/{attachment_id}/file")
|
||||
def get_attachment_file(attachment_id: int):
|
||||
conn = get_conn()
|
||||
att = models.get_attachment(conn, attachment_id)
|
||||
conn.close()
|
||||
if not att:
|
||||
raise HTTPException(404, f"Attachment #{attachment_id} not found")
|
||||
file_path = Path(att["path"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "Attachment file not found on disk")
|
||||
return FileResponse(
|
||||
str(file_path),
|
||||
media_type=att["mime_type"],
|
||||
filename=att["filename"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat (KIN-OBS-012)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ async function del<T>(path: string): Promise<T> {
|
|||
return res.json()
|
||||
}
|
||||
|
||||
async function postForm<T>(path: string, body: FormData): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, { method: 'POST', body })
|
||||
if (!res.ok) await throwApiError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -270,6 +276,15 @@ export interface ChatSendResult {
|
|||
task?: Task | null
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number
|
||||
task_id: string
|
||||
filename: string
|
||||
mime_type: string
|
||||
size: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const api = {
|
||||
projects: () => get<Project[]>('/projects'),
|
||||
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
|
||||
|
|
@ -339,4 +354,14 @@ export const api = {
|
|||
get<ChatMessage[]>(`/projects/${projectId}/chat?limit=${limit}`),
|
||||
sendChatMessage: (projectId: string, content: string) =>
|
||||
post<ChatSendResult>(`/projects/${projectId}/chat`, { content }),
|
||||
uploadAttachment: (taskId: string, file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return postForm<Attachment>(`/tasks/${taskId}/attachments`, fd)
|
||||
},
|
||||
getAttachments: (taskId: string) =>
|
||||
get<Attachment[]>(`/tasks/${taskId}/attachments`),
|
||||
deleteAttachment: (taskId: string, id: number) =>
|
||||
del<void>(`/tasks/${taskId}/attachments/${id}`),
|
||||
attachmentUrl: (id: number) => `${BASE}/attachments/${id}/file`,
|
||||
}
|
||||
|
|
|
|||
55
web/frontend/src/components/AttachmentList.vue
Normal file
55
web/frontend/src/components/AttachmentList.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { api, type Attachment } from '../api'
|
||||
|
||||
const props = defineProps<{ attachments: Attachment[]; taskId: string }>()
|
||||
const emit = defineEmits<{ deleted: [] }>()
|
||||
|
||||
const deletingId = ref<number | null>(null)
|
||||
|
||||
async function remove(id: number) {
|
||||
deletingId.value = id
|
||||
try {
|
||||
await api.deleteAttachment(props.taskId, id)
|
||||
emit('deleted')
|
||||
} catch {
|
||||
// silently ignore — parent will reload
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="attachments.length" class="flex flex-wrap gap-3 mb-3">
|
||||
<div
|
||||
v-for="att in attachments"
|
||||
:key="att.id"
|
||||
class="relative group border border-gray-700 rounded-lg overflow-hidden bg-gray-900 w-28"
|
||||
>
|
||||
<a :href="api.attachmentUrl(att.id)" target="_blank" rel="noopener">
|
||||
<img
|
||||
:src="api.attachmentUrl(att.id)"
|
||||
:alt="att.filename"
|
||||
class="w-28 h-20 object-cover block"
|
||||
/>
|
||||
</a>
|
||||
<div class="px-1.5 py-1">
|
||||
<p class="text-[10px] text-gray-400 truncate" :title="att.filename">{{ att.filename }}</p>
|
||||
<p class="text-[10px] text-gray-600">{{ formatSize(att.size) }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="remove(att.id)"
|
||||
:disabled="deletingId === att.id"
|
||||
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-red-900/80 text-red-400 text-xs leading-none opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 flex items-center justify-center"
|
||||
title="Удалить"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
62
web/frontend/src/components/AttachmentUploader.vue
Normal file
62
web/frontend/src/components/AttachmentUploader.vue
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
|
||||
const props = defineProps<{ taskId: string }>()
|
||||
const emit = defineEmits<{ uploaded: [] }>()
|
||||
|
||||
const dragging = ref(false)
|
||||
const uploading = ref(false)
|
||||
const error = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function upload(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error.value = 'Поддерживаются только изображения'
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.uploadAttachment(props.taskId, file)
|
||||
emit('uploaded')
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files?.[0]) upload(input.files[0])
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (file) upload(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-3 text-center transition-colors cursor-pointer select-none"
|
||||
:class="dragging ? 'border-blue-500 bg-blue-950/20' : 'border-gray-700 hover:border-gray-500'"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" />
|
||||
<div v-if="uploading" class="flex items-center justify-center gap-2 text-xs text-blue-400">
|
||||
<span class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></span>
|
||||
Загрузка...
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-500">
|
||||
Перетащите изображение или <span class="text-blue-400">нажмите для выбора</span>
|
||||
</div>
|
||||
<p v-if="error" class="text-red-400 text-xs mt-1">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, ApiError, type TaskFull, type PipelineStep, type PendingAction, type DeployResult } from '../api'
|
||||
import { api, ApiError, type TaskFull, type PipelineStep, type PendingAction, type DeployResult, type Attachment } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
import AttachmentUploader from '../components/AttachmentUploader.vue'
|
||||
import AttachmentList from '../components/AttachmentList.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const route = useRoute()
|
||||
|
|
@ -92,7 +94,7 @@ function stopPolling() {
|
|||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => { load(); loadAttachments() })
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
function statusColor(s: string) {
|
||||
|
|
@ -291,6 +293,15 @@ async function runDeploy() {
|
|||
}
|
||||
}
|
||||
|
||||
// Attachments
|
||||
const attachments = ref<Attachment[]>([])
|
||||
|
||||
async function loadAttachments() {
|
||||
try {
|
||||
attachments.value = await api.getAttachments(props.id)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Edit modal (pending tasks only)
|
||||
const showEdit = ref(false)
|
||||
const editForm = ref({ title: '', briefText: '', priority: 5, acceptanceCriteria: '' })
|
||||
|
|
@ -474,6 +485,13 @@ async function saveEdit() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-2">Вложения</h2>
|
||||
<AttachmentList :attachments="attachments" :task-id="props.id" @deleted="loadAttachments" />
|
||||
<AttachmentUploader :task-id="props.id" @uploaded="loadAttachments" />
|
||||
</div>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<div class="sticky bottom-0 bg-gray-950 border-t border-gray-800 py-3 flex gap-3 -mx-6 px-6 mt-8">
|
||||
<div v-if="autoMode && (isRunning || task.status === 'review')"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue