feat(updates): check Gitea automatically and deploy on owner confirmation

Add owner-only update controls, a read-only SSH checker and independent systemd deployment services. Build before maintenance; back up code and database; restore on failed deployment. Preserve runtime configuration and gate business writes during the switch.
This commit is contained in:
WangShen007 committed 2026-10-24 07:26:41 +00:00
1 parent ed8dc01ed9
commit f3ad539998
28 files changed
+1346 -1

No files matched your search

@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { computed, ref } from 'vue'
import SystemUpdates from '@/views/settings/SystemUpdates.vue'
import type { UpdateStatus } from '@/services/systemUpdates'
const state = ref<UpdateStatus>({ configured: true, can_manage: true })
const apply = vi.fn().mockResolvedValue(undefined)
const check = vi.fn().mockResolvedValue(undefined)
vi.mock('@/composables/useSystemUpdates', () => ({ useSystemUpdates: () => ({
state, busy: computed(() => state.value.update?.status === 'building'), error: ref(''),
refresh: vi.fn().mockResolvedValue(undefined), check, apply,
}) }))
vi.mock('@/layouts/DashboardLayout.vue', () => ({ default: { template: '<div><slot /></div>' } }))
beforeEach(() => {
vi.clearAllMocks()
state.value = { configured: true, can_manage: true, current: { revision: 'a'.repeat(40), subject: 'current' },
latest: { revision: 'b'.repeat(40), subject: 'new' }, check_interval_minutes: 30, update_available: true,
update: { status: 'idle', message: '' } }
})
describe('SystemUpdates', () => {
it('requires explicit confirmation before starting an update', async () => {
const wrapper = mount(SystemUpdates)
await flushPromises()
const button = wrapper.findAll('button').find(b => b.text() === '更新到此版本')!
await button.trigger('click')
expect(wrapper.find('[role=dialog]').exists()).toBe(true)
expect(apply).not.toHaveBeenCalled()
await wrapper.findAll('button').find(b => b.text() === '备份并开始更新')!.trigger('click')
await flushPromises()
expect(apply).toHaveBeenCalledOnce()
wrapper.unmount()
})
it('disables installation when there is no newer version or an update is running', async () => {
state.value.update_available = false
const wrapper = mount(SystemUpdates)
await flushPromises()
expect(wrapper.find('.update-actions button').attributes('disabled')).toBeDefined()
state.value.update_available = true
state.value.update = { status: 'building', message: '正在构建' }
await flushPromises()
expect(wrapper.find('.update-actions button').attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('正在构建')
wrapper.unmount()
})
it('shows a failed update and recovery result', async () => {
state.value.update = { status: 'rolled_back', message: '已恢复原版本', error: '构建失败' }
const wrapper = mount(SystemUpdates)
await flushPromises()
expect(wrapper.text()).toContain('已恢复原版本')
expect(wrapper.text()).toContain('构建失败')
wrapper.unmount()
})
})
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { hasPermission } from '@/utils/permissions'
import { useSystemUpdates } from '@/composables/useSystemUpdates'
const { state, busy, refresh } = useSystemUpdates()
let timer: ReturnType<typeof setInterval> | undefined
onMounted(() => {
if (!hasPermission('super_admin')) return
void refresh()
timer = setInterval(() => { void refresh() }, 60000)
})
onUnmounted(() => clearInterval(timer))
</script>
<template>
<RouterLink v-if="state?.can_manage && (state.update_available || busy)"
class="update-notice" to="/settings/system-updates">
<span class="pulse"></span>{{ busy ? '正在更新' : '有新版本' }}
</RouterLink>
</template>
<style scoped>
.update-notice { display: inline-flex; align-items: center; gap: 8px; padding: 7px 12px; border: 1px solid var(--celadon-line); border-radius: var(--radius-pill); color: var(--celadon); background: var(--celadon-soft); font-size: 12px; white-space: nowrap; }
</style>
@@ -15,6 +15,7 @@ limitations under the License.
*/
import { computed } from 'vue'
import { useSystemUpdates } from '@/composables/useSystemUpdates'
import { permissionChecks } from '@/utils/permissions'
// Visibility comes from the same map the router enforces, so a link can never
// appear for a page the guard will refuse (People used to do exactly that).
@@ -47,6 +48,7 @@ export const formatBadgeCount = (count?: number) =>
export const PRIMARY_NAV_PATHS = ['/conversations', '/people', '/ai-agents', '/analytics']
export function useNavItems() {
const { state: updateState } = useSystemUpdates()
const { hasEnterpriseModule } = useEnterpriseFeatures()
// Section membership is explicit rather than inferred from array position:
@@ -167,6 +169,12 @@ export function useNavItems() {
label: 'AI 配置',
show: canAccessPath('/settings/ai-config'),
},
{
to: '/settings/system-updates',
icon: 'integrations',
label: '系统更新',
show: updateState.value?.can_manage === true && canAccessPath('/settings/system-updates'),
},
{
to: '/settings/user',
icon: 'usersettings',
@@ -0,0 +1,34 @@
import { computed, ref } from 'vue'
import { systemUpdates, type UpdateStatus } from '@/services/systemUpdates'
const state = ref<UpdateStatus | null>(null)
const error = ref('')
let inFlight: Promise<void> | null = null
const busyStates = new Set(['queued', 'preparing', 'building', 'backing_up', 'deploying', 'migrating', 'verifying', 'rolling_back'])
export function useSystemUpdates() {
const busy = computed(() => busyStates.has(state.value?.update?.status || ''))
const refresh = (): Promise<void> => {
inFlight ??= systemUpdates.status().then(data => {
state.value = data
error.value = ''
}).catch((reason: unknown) => {
const detail = (reason as { response?: { data?: { detail?: string } } })?.response?.data?.detail
error.value = busy.value ? '后台正在切换版本,连接恢复后会自动显示结果。' : detail || '暂时无法读取更新状态,请稍后重试。'
}).finally(() => { inFlight = null })
return inFlight
}
const check = async () => {
await systemUpdates.check()
if (state.value) state.value.checking = true
await refresh()
}
const apply = async () => {
const revision = state.value?.latest?.revision
if (!revision) return
await systemUpdates.apply(revision)
if (state.value) state.value.update = { status: 'queued', message: '更新已排队。', target_revision: revision }
await refresh()
}
return { state, error, busy, refresh, check, apply }
}
+3
View File
@@ -18,6 +18,7 @@ limitations under the License.
import { ref, onMounted, watch, computed } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { useTheme } from '@/composables/useTheme'
import SystemUpdateNotice from '@/components/layout/SystemUpdateNotice.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue'
import BottomNav from '@/components/layout/BottomNav.vue'
import MoreSheet from '@/components/layout/MoreSheet.vue'
@@ -83,6 +84,7 @@ const PAGE_TITLES: Record<string, string> = {
'/settings/widget-apps': '网站组件',
'/settings/ai-config': 'AI 配置',
'/settings/user': '个人设置',
'/settings/system-updates': '系统更新',
}
const pageTitle = computed(() => PAGE_TITLES[route.path] || '')
@@ -319,6 +321,7 @@ const openNotificationsFromSheet = () => {
<div class="topbar-spacer"></div>
<SystemUpdateNotice />
<SupportChatButton v-if="hasEnterpriseModule" />
<PromoHeaderPill v-if="hasEnterpriseModule" />
<div v-if="hasEnterpriseModule && (isLoadingPlan || isInTrial)" class="plan-display">
+6
View File
@@ -155,6 +155,12 @@ const baseRoutes = [
component: HumanAgentView,
meta: { requiresAuth: true },
},
{
path: '/settings/system-updates',
name: 'system-updates',
component: () => import('@/views/settings/SystemUpdates.vue'),
meta: { requiresAuth: true, layout: 'dashboard' },
},
{
path: '/settings/organization',
name: 'organization-settings',
+1
View File
@@ -47,6 +47,7 @@ export const ROUTE_PERMISSIONS: Record<string, readonly string[]> = {
'/faq': ['manage_knowledge'],
'/tickets': TICKET_PERMISSIONS,
'/tickets/:id': TICKET_PERMISSIONS,
'/settings/system-updates': ['super_admin'],
'/settings/ticketing': ['manage_organization'],
'/conversations': CHAT_VIEW_PERMISSIONS,
'/people': PEOPLE_PERMISSIONS,
+34
View File
@@ -0,0 +1,34 @@
import api from './api'
export interface Revision {
revision: string
subject: string
date?: string
}
export interface UpdateStatus {
configured: boolean
can_manage: boolean
current?: Revision
latest?: Revision | null
repository_url?: string
branch?: string
check_interval_minutes?: number
checked_at?: string | null
checking?: boolean
check_error?: string | null
update_available?: boolean
update?: {
status: string
message: string
error?: string | null
target_revision?: string
started_at?: string
finished_at?: string
}
}
export const systemUpdates = {
status: () => api.get<UpdateStatus>('/system-updates').then(r => r.data),
check: () => api.post('/system-updates/check'),
apply: (revision: string) => api.post('/system-updates/apply', { revision }),
}
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import DashboardLayout from '@/layouts/DashboardLayout.vue'
import { useSystemUpdates } from '@/composables/useSystemUpdates'
const { state, error, busy, refresh, check, apply } = useSystemUpdates()
const confirming = ref(false)
const dialog = ref<HTMLElement | null>(null)
const updateButton = ref<HTMLButtonElement | null>(null)
watch(confirming, async open => {
await nextTick()
if (open) dialog.value?.querySelector<HTMLButtonElement>('button')?.focus()
else updateButton.value?.focus()
})
const trapFocus = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return
const buttons = [...(dialog.value?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') || [])]
const first = buttons[0], last = buttons[buttons.length - 1]
if (!first || (event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last)) {
event.preventDefault()
;(event.shiftKey ? last : first)?.focus()
}
}
const pending = ref(false)
const actionError = ref('')
const canUpdate = computed(() => state.value?.update_available && !busy.value && !state.value.checking && state.value.update?.status !== 'recovery_failed' && !pending.value)
const resultTitle = computed(() => {
if (busy.value) return '更新进行中'
if (state.value?.check_error) return '暂时无法检查新版本'
if (state.value?.update_available) return '有新版本可以更新'
return '当前已是最新版本'
})
const formatTime = (value?: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未检查'
const short = (value?: string) => value?.slice(0, 8) || '—'
const run = async (action: () => Promise<void>) => {
pending.value = true
actionError.value = ''
try { await action(); confirming.value = false }
catch (reason: unknown) {
actionError.value = (reason as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '请求未完成,请刷新状态后再试。'
} finally { pending.value = false }
}
let disposed = false
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
await refresh()
if (!disposed) timer = setTimeout(poll, busy.value || state.value?.checking ? 2000 : 60000)
}
onMounted(() => { void poll() })
onUnmounted(() => { disposed = true; clearTimeout(timer) })
</script>
<template>
<DashboardLayout>
<div class="updates-page">
<div class="eyebrow">SYSTEM · 系统</div>
<div class="page-heading"><div><h1>系统更新</h1><p>自动检查 Gitea,准备好后再由你决定更新。</p></div>
<button class="btn btn-secondary" :disabled="busy || pending || state?.checking" @click="run(check)">
{{ state?.checking ? '正在检查…' : '检查更新' }}
</button>
</div>
<p v-if="!state" role="status" class="muted">正在读取版本信息…</p>
<p v-else-if="!state.can_manage" class="muted">系统更新仅对部署管理员开放。</p>
<template v-else>
<section class="panel update-panel" aria-labelledby="update-title">
<div class="status-heading"><span class="status-dot" :class="{ available: state.update_available }"></span>
<h2 id="update-title">{{ resultTitle }}</h2></div>
<p class="muted">每 {{ state.check_interval_minutes }} 分钟自动检查一次,只检查、不自动安装。</p>
<div class="versions">
<div><span class="version-label">当前版本</span><code>{{ short(state.current?.revision) }}</code><p>{{ state.current?.subject }}</p></div>
<div><span class="version-label">Gitea · {{ state.branch }}</span><code>{{ short(state.latest?.revision) }}</code><p>{{ state.latest?.subject || '等待检查结果' }}</p></div>
</div>
<div v-if="busy" class="progress-note" role="status" aria-live="polite"><span class="spinner"></span>{{ state.update?.message }}</div>
<p v-else-if="state.update?.status === 'succeeded'" class="success" role="status">{{ state.update.message }} · {{ formatTime(state.update.finished_at) }}</p>
<div v-else-if="state.update?.error" role="alert" class="error-note"><strong>{{ state.update.message }}</strong><p>{{ state.update.error }}</p></div>
<p v-if="state.check_error" role="alert" class="error-note">{{ state.check_error }}</p>
<div class="update-actions"><span class="muted">上次检查:{{ formatTime(state.checked_at) }}</span>
<button ref="updateButton" class="btn btn-primary" :disabled="!canUpdate" @click="confirming = true">{{ busy ? '正在更新…' : '更新到此版本' }}</button>
</div>
</section>
<p class="update-note">更新前自动备份程序和数据库。构建时可正常接待,切换版本时会短暂停止接待;失败后自动恢复原版本。</p>
<a v-if="state.repository_url" :href="state.repository_url" target="_blank" rel="noopener noreferrer" class="repo-link">在 Gitea 查看源码 ↗</a>
</template>
<p v-if="error || actionError" role="alert" class="error-note">{{ actionError || error }}</p>
<div v-if="confirming" class="confirm-backdrop" @click.self="!pending && (confirming = false)" @keydown.esc="!pending && (confirming = false)">
<section ref="dialog" role="dialog" @keydown="trapFocus" aria-modal="true" aria-labelledby="confirm-title" class="confirm-panel">
<div class="eyebrow">确认更新</div><h2 id="confirm-title">更新到 {{ short(state?.latest?.revision) }}?</h2>
<p>切换版本时会短暂停止接待。请先处理正在进行的会话,更新完成后页面会自动显示结果。</p>
<div class="confirm-actions"><button class="btn btn-secondary" :disabled="pending" @click="confirming = false">暂不更新</button>
<button class="btn btn-primary" :disabled="pending" @click="run(apply)">{{ pending ? '正在提交…' : '备份并开始更新' }}</button></div>
<p v-if="actionError" role="alert" class="error-note">{{ actionError }}</p>
</section>
</div>
</div>
</DashboardLayout>
</template>
<style scoped>
.updates-page { max-width: 1040px; margin: 0 auto; }
.page-heading { display: flex; justify-content: space-between; gap: 24px; align-items: center; margin: 22px 0 38px; }
h1 { font: 600 38px/1.3 var(--font-serif); margin-bottom: 12px; }
.page-heading p, .update-note { color: var(--muted); line-height: 1.8; }
.update-panel { padding: 30px; }
.status-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
h2 { font-size: 20px; font-weight: 600; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--celadon); }
.status-dot.available { background: var(--vermilion); }
.muted { color: var(--muted); font-size: 13px; line-height: 1.7; }
.versions { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin: 28px 0; padding: 26px 0; border-block: 1px solid var(--line); }
.version-label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 10px; }
code { font: 24px var(--font-mono); color: var(--ink); }
.versions p { font-size: 13px; color: var(--muted); margin-top: 8px; overflow-wrap: anywhere; }
.update-actions, .confirm-actions { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.update-note { font-size: 13px; margin: 22px 0 10px; }
.repo-link { font-size: 13px; color: var(--celadon); }
.error-note { color: var(--vermilion); background: var(--vermilion-soft); padding: 12px 16px; border-radius: var(--radius-md); margin-block: 16px; font-size: 13px; overflow-wrap: anywhere; }
.progress-note { display: flex; align-items: center; gap: 12px; color: var(--celadon); margin: 18px 0; font-size: 14px; }
.success { color: var(--celadon); margin-bottom: 18px; font-size: 13px; }
.spinner { width: 16px; height: 16px; border: 2px solid var(--celadon-line); border-top-color: var(--celadon); border-radius: 50%; animation: spin 1s linear infinite; }
.confirm-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); background: var(--scrim); display: grid; place-items: center; padding: 24px; }
.confirm-panel { max-width: 480px; background: var(--surface); padding: 32px; border: 1px solid var(--line); border-radius: var(--radius-card-lg); box-shadow: var(--shadow-lg); }
.confirm-panel h2 { margin: 20px 0 16px; }
.confirm-panel p { color: var(--muted); line-height: 1.8; font-size: 14px; }
.confirm-actions { justify-content: flex-end; margin-top: 28px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 640px) { .page-heading { align-items: flex-start; flex-direction: column; gap: 18px; } h1 { font-size: 30px; } .update-panel { padding: 22px; } .versions { grid-template-columns: 1fr; gap: 24px; } .update-actions { align-items: flex-start; flex-direction: column; } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
</style>