kin: KIN-120-frontend_dev

This commit is contained in:
Gros Frumos 2026-03-18 14:30:36 +02:00
parent e3a286ef6f
commit a202210b9f
5 changed files with 333 additions and 560 deletions

View file

@ -2,7 +2,7 @@
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink } from '../api'
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink, type ObsidianSyncResult } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
@ -14,7 +14,7 @@ const { t } = useI18n()
const project = ref<ProjectDetail | null>(null)
const loading = ref(true)
const error = ref('')
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links'>('tasks')
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links' | 'settings'>('tasks')
// Phases
const phases = ref<Phase[]>([])
@ -276,6 +276,99 @@ async function toggleWorktrees() {
}
}
// Settings form
const settingsForm = ref({
execution_mode: 'review',
autocommit_enabled: false,
auto_test_enabled: false,
worktrees_enabled: false,
test_command: '',
deploy_host: '',
deploy_path: '',
deploy_runtime: '',
deploy_restart_cmd: '',
deploy_command: '',
obsidian_vault_path: '',
ssh_host: '',
ssh_user: '',
ssh_key_path: '',
ssh_proxy_jump: '',
})
const settingsSaving = ref(false)
const settingsSaveStatus = ref('')
const syncingObsidian = ref(false)
const obsidianSyncResult = ref<ObsidianSyncResult | null>(null)
function loadSettingsForm() {
if (!project.value) return
settingsForm.value = {
execution_mode: project.value.execution_mode ?? 'review',
autocommit_enabled: !!(project.value.autocommit_enabled),
auto_test_enabled: !!(project.value.auto_test_enabled),
worktrees_enabled: !!(project.value.worktrees_enabled),
test_command: project.value.test_command ?? '',
deploy_host: project.value.deploy_host ?? '',
deploy_path: project.value.deploy_path ?? '',
deploy_runtime: project.value.deploy_runtime ?? '',
deploy_restart_cmd: project.value.deploy_restart_cmd ?? '',
deploy_command: project.value.deploy_command ?? '',
obsidian_vault_path: project.value.obsidian_vault_path ?? '',
ssh_host: project.value.ssh_host ?? '',
ssh_user: project.value.ssh_user ?? '',
ssh_key_path: project.value.ssh_key_path ?? '',
ssh_proxy_jump: project.value.ssh_proxy_jump ?? '',
}
}
async function saveSettings() {
settingsSaving.value = true
settingsSaveStatus.value = ''
try {
const updated = await api.patchProject(props.id, {
execution_mode: settingsForm.value.execution_mode,
autocommit_enabled: settingsForm.value.autocommit_enabled,
auto_test_enabled: settingsForm.value.auto_test_enabled,
worktrees_enabled: settingsForm.value.worktrees_enabled,
test_command: settingsForm.value.test_command,
deploy_host: settingsForm.value.deploy_host,
deploy_path: settingsForm.value.deploy_path,
deploy_runtime: settingsForm.value.deploy_runtime,
deploy_restart_cmd: settingsForm.value.deploy_restart_cmd,
deploy_command: settingsForm.value.deploy_command,
obsidian_vault_path: settingsForm.value.obsidian_vault_path,
ssh_host: settingsForm.value.ssh_host,
ssh_user: settingsForm.value.ssh_user,
ssh_key_path: settingsForm.value.ssh_key_path,
ssh_proxy_jump: settingsForm.value.ssh_proxy_jump,
})
if (project.value) {
project.value = { ...project.value, ...updated }
loadMode()
loadAutocommit()
loadAutoTest()
loadWorktrees()
}
settingsSaveStatus.value = t('common.saved')
} catch (e: any) {
settingsSaveStatus.value = `${t('common.error')}: ${e.message}`
} finally {
settingsSaving.value = false
}
}
async function syncObsidianVault() {
syncingObsidian.value = true
obsidianSyncResult.value = null
try {
await api.patchProject(props.id, { obsidian_vault_path: settingsForm.value.obsidian_vault_path })
obsidianSyncResult.value = await api.syncObsidian(props.id)
} catch (e: any) {
settingsSaveStatus.value = `${t('common.error')}: ${e.message}`
} finally {
syncingObsidian.value = false
}
}
// Audit
const auditLoading = ref(false)
const auditResult = ref<AuditResult | null>(null)
@ -504,6 +597,7 @@ async function load() {
loadAutocommit()
loadAutoTest()
loadWorktrees()
loadSettingsForm()
} catch (e: any) {
error.value = e.message
} finally {
@ -534,6 +628,9 @@ onMounted(async () => {
const all = await api.projects()
allProjects.value = all.map(p => ({ id: p.id, name: p.name }))
} catch {}
if (route.query.tab === 'settings') {
activeTab.value = 'settings'
}
})
onUnmounted(() => {
@ -874,13 +971,13 @@ async function addDecision() {
<!-- Tabs -->
<div class="flex gap-1 mb-4 border-b border-gray-800 flex-wrap">
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban', 'environments', 'links'] as const)" :key="tab"
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban', 'environments', 'links', 'settings'] 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 === 'tasks' ? t('projectView.tasks_tab') : tab === 'phases' ? t('projectView.phases_tab') : tab === 'decisions' ? t('projectView.decisions_tab') : tab === 'modules' ? t('projectView.modules_tab') : tab === 'kanban' ? t('projectView.kanban_tab') : tab === 'environments' ? t('projectView.environments') : t('projectView.links_tab') }}
{{ tab === 'tasks' ? t('projectView.tasks_tab') : tab === 'phases' ? t('projectView.phases_tab') : tab === 'decisions' ? t('projectView.decisions_tab') : tab === 'modules' ? t('projectView.modules_tab') : tab === 'kanban' ? t('projectView.kanban_tab') : tab === 'environments' ? t('projectView.environments') : tab === 'links' ? t('projectView.links_tab') : t('projectView.settings_tab') }}
<span class="text-xs text-gray-600 ml-1">
{{ tab === 'tasks' ? project.tasks.length
: tab === 'phases' ? phases.length
@ -888,7 +985,8 @@ async function addDecision() {
: tab === 'modules' ? project.modules.length
: tab === 'environments' ? environments.length
: tab === 'links' ? links.length
: project.tasks.length }}
: tab === 'kanban' ? project.tasks.length
: '' }}
</span>
</button>
</div>
@ -1444,6 +1542,138 @@ async function addDecision() {
</Modal>
</div>
<!-- Settings Tab -->
<div v-if="activeTab === 'settings'" class="space-y-6 max-w-2xl">
<!-- Agent Execution Section -->
<div>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">{{ t('projectView.settings_agent_section') }}</p>
<div class="space-y-3">
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('projectView.settings_execution_mode') }}</label>
<select v-model="settingsForm.execution_mode"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-gray-500">
<option value="review">review</option>
<option value="auto_complete">auto_complete</option>
</select>
</div>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" v-model="settingsForm.autocommit_enabled" class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer" />
<span class="text-sm text-gray-300">{{ t('projectView.settings_autocommit') }}</span>
<span class="text-xs text-gray-500">{{ t('projectView.settings_autocommit_hint') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" v-model="settingsForm.auto_test_enabled" class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer" />
<span class="text-sm text-gray-300">{{ t('settings.auto_test') }}</span>
<span class="text-xs text-gray-500">{{ t('settings.auto_test_hint') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" v-model="settingsForm.worktrees_enabled" class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer" />
<span class="text-sm text-gray-300">{{ t('settings.worktrees') }}</span>
<span class="text-xs text-gray-500">{{ t('settings.worktrees_hint') }}</span>
</label>
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.test_command') }}</label>
<input v-model="settingsForm.test_command" type="text" placeholder="make test"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
<p class="text-xs text-gray-600 mt-1">{{ t('settings.test_command_hint') }}</p>
</div>
</div>
</div>
<!-- Deploy Section -->
<div class="pt-4 border-t border-gray-800">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">{{ t('projectView.settings_deploy_section') }}</p>
<div class="space-y-3">
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.server_host') }}</label>
<input v-model="settingsForm.deploy_host" type="text" placeholder="vdp-prod"
class="w-full bg-gray-900 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>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.project_path_on_server') }}</label>
<input v-model="settingsForm.deploy_path" type="text" placeholder="/srv/myproject"
class="w-full bg-gray-900 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>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.runtime') }}</label>
<select v-model="settingsForm.deploy_runtime"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-gray-500">
<option value="">{{ t('settings.select_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>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.restart_command') }}</label>
<input v-model="settingsForm.deploy_restart_cmd" type="text" placeholder="optional override command"
class="w-full bg-gray-900 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>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.fallback_command') }}</label>
<input v-model="settingsForm.deploy_command" type="text" placeholder="git push origin main"
class="w-full bg-gray-900 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>
</div>
<!-- Integrations Section -->
<div class="pt-4 border-t border-gray-800">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">{{ t('projectView.settings_integrations_section') }}</p>
<div class="space-y-3">
<div>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.obsidian_vault_path') }}</label>
<div class="flex gap-2">
<input v-model="settingsForm.obsidian_vault_path" type="text" placeholder="/path/to/obsidian/vault"
class="flex-1 bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500" />
<button @click="syncObsidianVault" :disabled="syncingObsidian || !settingsForm.obsidian_vault_path"
class="px-3 py-1.5 text-sm bg-indigo-900/50 text-indigo-400 border border-indigo-800 rounded hover:bg-indigo-900 disabled:opacity-50">
{{ syncingObsidian ? t('settings.syncing') : t('settings.sync_obsidian') }}
</button>
</div>
<div v-if="obsidianSyncResult" class="mt-2 p-2 bg-gray-900 rounded text-xs text-gray-300 flex gap-3">
<span>{{ obsidianSyncResult.exported_decisions }} decisions</span>
<span>{{ obsidianSyncResult.tasks_updated }} tasks</span>
</div>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">SSH Host</label>
<input v-model="settingsForm.ssh_host" type="text" placeholder="vdp-prod"
class="w-full bg-gray-900 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>
<label class="block text-xs text-gray-500 mb-1">SSH User</label>
<input v-model="settingsForm.ssh_user" type="text" placeholder="root"
class="w-full bg-gray-900 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>
<label class="block text-xs text-gray-500 mb-1">SSH Key Path</label>
<input v-model="settingsForm.ssh_key_path" type="text" placeholder="~/.ssh/id_rsa"
class="w-full bg-gray-900 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>
<label class="block text-xs text-gray-500 mb-1">SSH ProxyJump</label>
<input v-model="settingsForm.ssh_proxy_jump" type="text" placeholder="jumpt"
class="w-full bg-gray-900 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>
</div>
<!-- Save Button -->
<div class="flex items-center gap-3 pt-4 border-t border-gray-800">
<button @click="saveSettings" :disabled="settingsSaving"
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">
{{ settingsSaving ? t('common.saving') : t('common.save') }}
</button>
<span v-if="settingsSaveStatus" class="text-xs"
:class="settingsSaveStatus.startsWith(t('common.error')) ? 'text-red-400' : 'text-green-400'">
{{ settingsSaveStatus }}
</span>
</div>
</div>
<!-- Add Task Modal -->
<Modal v-if="showAddTask" title="Add Task" @close="closeAddTaskModal">
<form @submit.prevent="addTask" class="space-y-3">