feat(gitsync): synka wiki-repot mot remote över SSH
- ed25519-deploy-nyckel genereras av servern (config-volymen, 0600), GIT_SSH_COMMAND med egen known_hosts (accept-new) - inställningar: remote-URL, live-gren, read-only, auto-synk-intervall - bakgrundssynk: endast fast-forward, flaggar attention vid merge-behov - pull/push, skapa/byt gren, merge-popup (ours/theirs per fil eller allt), abort, reset från remote med automatisk backup-gren - headless-konfig via config.json eller updateGitSync-mutationen - openssh-client tillagd i runtime-imagen; mutex kring alla git-operationer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
325
frontend/src/components/GitSyncSection.vue
Normal file
325
frontend/src/components/GitSyncSection.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { gql } from '@/lib/gql'
|
||||
import MergeConflictDialog from '@/components/MergeConflictDialog.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'status', msg: string, isError?: boolean): void
|
||||
}>()
|
||||
|
||||
interface GitSettings {
|
||||
enabled: boolean; remoteUrl: string; liveBranch: string
|
||||
readOnly: boolean; autoSyncMinutes: number; publicKey: string; keySet: boolean
|
||||
}
|
||||
interface GitStatus {
|
||||
enabled: boolean; currentBranch: string; liveBranch: string; readOnly: boolean
|
||||
ahead: number; behind: number; hasUpstream: boolean; dirty: boolean
|
||||
mergeInProgress: boolean; conflicts: string[]
|
||||
lastSyncAt: string; lastSyncError: string; attention: boolean
|
||||
}
|
||||
|
||||
const settings = ref<GitSettings>({
|
||||
enabled: false, remoteUrl: '', liveBranch: 'main',
|
||||
readOnly: false, autoSyncMinutes: 0, publicKey: '', keySet: false,
|
||||
})
|
||||
const status = ref<GitStatus | null>(null)
|
||||
const branches = ref<{ current: string; local: string[]; remote: string[] }>({ current: '', local: [], remote: [] })
|
||||
const busy = ref(false)
|
||||
const switchTarget = ref('')
|
||||
const newBranchName = ref('')
|
||||
const confirmReset = ref(false)
|
||||
const confirmRegen = ref(false)
|
||||
const showMergeDialog = ref(false)
|
||||
const suggestReset = ref(false)
|
||||
let poll: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
// Remote branches that have no local counterpart yet.
|
||||
const allBranches = computed(() => {
|
||||
const extra = branches.value.remote.filter(b => !branches.value.local.includes(b))
|
||||
return [...branches.value.local, ...extra.map(b => `${b} (remote)`)]
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
const d = await gql<{ gitSyncSettings: GitSettings }>(
|
||||
`{ gitSyncSettings { enabled remoteUrl liveBranch readOnly autoSyncMinutes publicKey keySet } }`)
|
||||
settings.value = d.gitSyncSettings
|
||||
}
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const d = await gql<{ gitSyncStatus: GitStatus }>(
|
||||
`{ gitSyncStatus { enabled currentBranch liveBranch readOnly ahead behind hasUpstream dirty mergeInProgress conflicts lastSyncAt lastSyncError attention } }`)
|
||||
status.value = d.gitSyncStatus
|
||||
if (d.gitSyncStatus.mergeInProgress && d.gitSyncStatus.conflicts.length > 0) showMergeDialog.value = true
|
||||
} catch { /* repo not ready yet — non-fatal */ }
|
||||
}
|
||||
async function loadBranches() {
|
||||
try {
|
||||
const d = await gql<{ gitBranches: { current: string; local: string[]; remote: string[] } }>(
|
||||
`{ gitBranches { current local remote } }`)
|
||||
branches.value = d.gitBranches
|
||||
switchTarget.value = d.gitBranches.current
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
async function refresh() {
|
||||
await Promise.all([loadStatus(), loadBranches()])
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
busy.value = true
|
||||
try {
|
||||
await gql(
|
||||
`mutation UpdateGitSync($input: GitSyncInput!) { updateGitSync(input: $input) }`,
|
||||
{ input: {
|
||||
enabled: settings.value.enabled,
|
||||
remoteUrl: settings.value.remoteUrl,
|
||||
liveBranch: settings.value.liveBranch,
|
||||
readOnly: settings.value.readOnly,
|
||||
autoSyncMinutes: Number(settings.value.autoSyncMinutes) || 0,
|
||||
} },
|
||||
)
|
||||
emit('status', 'Git sync settings saved.')
|
||||
await loadSettings()
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
await navigator.clipboard.writeText(settings.value.publicKey)
|
||||
emit('status', 'Public key copied to clipboard.')
|
||||
}
|
||||
|
||||
async function regenerateKey() {
|
||||
confirmRegen.value = false
|
||||
busy.value = true
|
||||
try {
|
||||
const d = await gql<{ gitRegenerateKey: { publicKey: string } }>(
|
||||
`mutation { gitRegenerateKey { publicKey } }`)
|
||||
settings.value.publicKey = d.gitRegenerateKey.publicKey
|
||||
settings.value.keySet = true
|
||||
emit('status', 'New keypair generated — update the deploy key on your git host.')
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function pull() {
|
||||
busy.value = true
|
||||
suggestReset.value = false
|
||||
try {
|
||||
const d = await gql<{ gitPull: { result: string; conflicts: string[] } }>(
|
||||
`mutation { gitPull { result conflicts } }`)
|
||||
if (d.gitPull.result === 'MERGE_CONFLICT') {
|
||||
showMergeDialog.value = true
|
||||
} else if (d.gitPull.result === 'UNRELATED_HISTORIES') {
|
||||
suggestReset.value = true
|
||||
} else {
|
||||
emit('status', 'Pulled from remote.')
|
||||
}
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function push() {
|
||||
busy.value = true
|
||||
try {
|
||||
await gql(`mutation { gitPush }`)
|
||||
emit('status', 'Pushed to remote.')
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function resolveMerge(strategy: 'ours' | 'theirs', paths?: string[]) {
|
||||
busy.value = true
|
||||
try {
|
||||
const d = await gql<{ gitResolveMerge: { resolved: boolean; remaining: string[] } }>(
|
||||
`mutation Resolve($strategy: String!, $paths: [String!]) { gitResolveMerge(strategy: $strategy, paths: $paths) { resolved remaining } }`,
|
||||
{ strategy, paths: paths ?? null },
|
||||
)
|
||||
if (d.gitResolveMerge.resolved) {
|
||||
showMergeDialog.value = false
|
||||
emit('status', 'Merge resolved and committed.')
|
||||
}
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function abortMerge() {
|
||||
busy.value = true
|
||||
try {
|
||||
await gql(`mutation { gitAbortMerge }`)
|
||||
showMergeDialog.value = false
|
||||
emit('status', 'Merge aborted.')
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function resetFromRemote() {
|
||||
confirmReset.value = false
|
||||
busy.value = true
|
||||
try {
|
||||
const d = await gql<{ gitResetFromRemote: { backupBranch: string } }>(
|
||||
`mutation { gitResetFromRemote { backupBranch } }`)
|
||||
suggestReset.value = false
|
||||
showMergeDialog.value = false
|
||||
const backup = d.gitResetFromRemote.backupBranch
|
||||
emit('status', backup ? `Reset from remote. Previous state kept in branch ${backup}.` : 'Reset from remote.')
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function createBranch() {
|
||||
const name = newBranchName.value.trim()
|
||||
if (!name) return
|
||||
busy.value = true
|
||||
try {
|
||||
await gql(`mutation Create($name: String!) { gitCreateBranch(name: $name) }`, { name })
|
||||
emit('status', `Created and switched to branch ${name}.`)
|
||||
newBranchName.value = ''
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function switchBranch() {
|
||||
const name = switchTarget.value.replace(/ \(remote\)$/, '')
|
||||
if (!name || name === branches.value.current) return
|
||||
busy.value = true
|
||||
try {
|
||||
await gql(`mutation Switch($name: String!) { gitSwitchBranch(name: $name) }`, { name })
|
||||
emit('status', `Switched to branch ${name}.`)
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try { await loadSettings() } catch { /* not initialised */ }
|
||||
if (settings.value.enabled) await refresh()
|
||||
poll = setInterval(() => { if (settings.value.enabled && !busy.value) loadStatus() }, 30000)
|
||||
})
|
||||
onUnmounted(() => { if (poll) clearInterval(poll) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card p-6">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 pb-2 mb-4">
|
||||
<h2 class="text-lg font-semibold">Git Sync (remote repository)</h2>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="settings.enabled" class="rounded border-slate-300 text-accent-600 focus:ring-accent-500" />
|
||||
<span class="text-sm font-medium">Enable</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">
|
||||
Sync the wiki with a remote git repository over SSH. Register the public key below as a
|
||||
deploy key (with write access unless read-only) on your git host.
|
||||
<span v-if="status?.attention" class="font-medium text-amber-600 dark:text-amber-400">Needs attention — see status below.</span>
|
||||
</p>
|
||||
|
||||
<div v-if="settings.enabled" class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label">Remote URL (SSH)</label>
|
||||
<input v-model="settings.remoteUrl" class="input font-mono text-sm" placeholder="ssh://git@git.example.com:2222/user/repo.git" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Live branch</label>
|
||||
<input v-model="settings.liveBranch" class="input" placeholder="main" />
|
||||
<p class="text-xs text-slate-500 mt-1">The branch auto-sync keeps in sync</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Auto-sync interval (minutes, 0 = manual)</label>
|
||||
<input v-model.number="settings.autoSyncMinutes" type="number" min="0" class="input" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="settings.readOnly" class="rounded border-slate-300 text-accent-600 focus:ring-accent-500" />
|
||||
<span class="text-sm font-medium">Read-only (pull from remote, never push)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy key -->
|
||||
<div>
|
||||
<label class="label">Deploy key (public)</label>
|
||||
<div v-if="settings.keySet" class="flex gap-2 items-start">
|
||||
<code class="flex-1 block text-xs font-mono bg-slate-100 dark:bg-slate-900/60 rounded-lg p-3 break-all select-all">{{ settings.publicKey }}</code>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button class="btn-secondary text-xs" @click="copyKey">Copy</button>
|
||||
<button class="btn-ghost text-xs" @click="confirmRegen = true">Regenerate</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-slate-500">The keypair is generated when sync is first enabled and saved.</p>
|
||||
<div v-if="confirmRegen" class="mt-2 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10 p-3 text-sm flex items-center justify-between gap-2">
|
||||
<span>The old key stops working immediately. Continue?</span>
|
||||
<span class="flex gap-2">
|
||||
<button class="btn-ghost text-xs" @click="confirmRegen = false">Cancel</button>
|
||||
<button class="btn-primary text-xs" :disabled="busy" @click="regenerateKey">Regenerate key</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button class="btn-primary" :disabled="busy" @click="saveSettings">Save Git Sync Config</button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div v-if="status?.enabled" class="rounded-lg border border-slate-200 dark:border-slate-700 p-4 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||
<span>Branch: <code class="font-mono">{{ status.currentBranch }}</code>
|
||||
<span v-if="status.currentBranch === status.liveBranch" class="ml-1 text-[10px] font-bold px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">live</span>
|
||||
</span>
|
||||
<span v-if="status.hasUpstream" class="text-slate-500">↑{{ status.ahead }} ↓{{ status.behind }}</span>
|
||||
<span v-else class="text-amber-600 dark:text-amber-400">no upstream yet</span>
|
||||
<span v-if="status.dirty" class="text-amber-600 dark:text-amber-400">uncommitted changes</span>
|
||||
<span v-if="status.mergeInProgress" class="text-red-600 dark:text-red-400 font-medium">merge in progress</span>
|
||||
<span v-if="status.lastSyncAt" class="text-slate-400 text-xs ml-auto">last sync {{ new Date(status.lastSyncAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
<p v-if="status.lastSyncError" class="text-xs text-red-600 dark:text-red-400 font-mono">{{ status.lastSyncError }}</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn-secondary" :disabled="busy" @click="pull">Pull</button>
|
||||
<button class="btn-secondary" :disabled="busy || settings.readOnly" :title="settings.readOnly ? 'Repository is read-only' : ''" @click="push">Push</button>
|
||||
<button v-if="status.mergeInProgress" class="btn-primary" :disabled="busy" @click="showMergeDialog = true">Resolve merge…</button>
|
||||
<button class="btn-ghost text-red-600 dark:text-red-400 ml-auto" :disabled="busy" @click="confirmReset = true">Reset from remote…</button>
|
||||
</div>
|
||||
|
||||
<div v-if="suggestReset" class="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10 p-3 text-sm">
|
||||
Local and remote histories are unrelated — the safe way forward is a reset from remote
|
||||
(current state is kept in a backup branch).
|
||||
</div>
|
||||
<div v-if="confirmReset" class="rounded-lg border border-red-300 dark:border-red-700 bg-red-50/50 dark:bg-red-900/10 p-3 text-sm flex items-center justify-between gap-2">
|
||||
<span>Replace the local <code class="font-mono">{{ status.currentBranch }}</code> with the remote version? Current state is saved in a backup branch.</span>
|
||||
<span class="flex gap-2 shrink-0">
|
||||
<button class="btn-ghost text-xs" @click="confirmReset = false">Cancel</button>
|
||||
<button class="btn-primary text-xs" :disabled="busy" @click="resetFromRemote">Reset</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Branches -->
|
||||
<div class="flex flex-wrap items-end gap-2 pt-2 border-t border-slate-100 dark:border-slate-700/50">
|
||||
<div>
|
||||
<label class="label">Switch branch</label>
|
||||
<div class="flex gap-2">
|
||||
<select v-model="switchTarget" class="input">
|
||||
<option v-for="b in allBranches" :key="b" :value="b">{{ b }}</option>
|
||||
</select>
|
||||
<button class="btn-secondary" :disabled="busy || switchTarget.replace(/ \(remote\)$/, '') === branches.current" @click="switchBranch">Switch</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<label class="label">New branch</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="newBranchName" class="input" placeholder="draft/my-changes" @keydown.enter.prevent="createBranch" />
|
||||
<button class="btn-secondary" :disabled="busy || !newBranchName.trim()" @click="createBranch">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MergeConflictDialog
|
||||
:is-open="showMergeDialog"
|
||||
:conflicts="status?.conflicts ?? []"
|
||||
:busy="busy"
|
||||
@close="showMergeDialog = false"
|
||||
@resolve="resolveMerge"
|
||||
@abort="abortMerge"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
58
frontend/src/components/MergeConflictDialog.vue
Normal file
58
frontend/src/components/MergeConflictDialog.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isOpen: boolean
|
||||
conflicts: string[]
|
||||
busy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'resolve', strategy: 'ours' | 'theirs', paths?: string[]): void
|
||||
(e: 'abort'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 backdrop-blur-sm p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Merge conflicts</h3>
|
||||
<button type="button" @click="emit('close')" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-4 bg-slate-50 dark:bg-slate-900/50 flex-1 overflow-auto flex flex-col gap-3">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
The pull stopped because these files were changed both locally and on the remote.
|
||||
Pick which version to keep — per file, or for everything at once.
|
||||
</p>
|
||||
<ul class="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg divide-y divide-slate-100 dark:divide-slate-700/50 overflow-y-auto">
|
||||
<li v-for="file in conflicts" :key="file" class="flex items-center gap-2 px-3 py-2">
|
||||
<span class="text-sm font-mono text-slate-700 dark:text-slate-300 truncate flex-1">{{ file }}</span>
|
||||
<button type="button" class="btn-secondary text-xs px-2 py-1" :disabled="busy" @click="emit('resolve', 'ours', [file])">Keep local</button>
|
||||
<button type="button" class="btn-secondary text-xs px-2 py-1" :disabled="busy" @click="emit('resolve', 'theirs', [file])">Take remote</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-4 border-t border-slate-200 dark:border-slate-700 flex flex-wrap justify-end gap-2 bg-white dark:bg-slate-800">
|
||||
<button type="button" class="btn-ghost mr-auto" :disabled="busy" @click="emit('abort')">Abort merge</button>
|
||||
<button type="button" class="btn-secondary" :disabled="busy" @click="emit('resolve', 'ours')">Keep all local</button>
|
||||
<button type="button" class="btn-primary" :disabled="busy" @click="emit('resolve', 'theirs')">Take all remote</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { gql } from '@/lib/gql'
|
||||
import { hashPassword } from '@/lib/crypto'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
import GitSyncSection from '@/components/GitSyncSection.vue'
|
||||
|
||||
// ── Shared ──────────────────────────────────────────────────────────────────
|
||||
const loading = ref(true)
|
||||
@@ -443,6 +444,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Git sync -->
|
||||
<GitSyncSection @status="showStatus" />
|
||||
|
||||
<!-- Admin password -->
|
||||
<section class="card p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">Your Password</h2>
|
||||
|
||||
Reference in New Issue
Block a user