feat(log): 优化操作日志记录和展示功能
- 在OperationLogEntity实体中新增summary和ipAddress字段存储摘要和IP信息 - 修改operationLogService.record方法支持传入操作摘要信息 - 实现客户端IP地址解析功能,支持X-Forwarded-For和X-Real-IP头 - 更新系统更新服务中的数据库表结构迁移逻辑,增加NOT NULL列处理 - 优化前端操作日志页面展示,添加标签分类和详情弹窗功能 - 在系统更新流式响应中增加网络连接异常处理机制 - 添加Nginx代理配置中的缓冲区设置以支持实时日志流式传输
这个提交包含在:
父节点
38e138f955
当前提交
4b13f64966
@ -120,6 +120,8 @@ export interface OpsLogItem {
|
|||||||
action: string
|
action: string
|
||||||
operator: string
|
operator: string
|
||||||
detailJson: string
|
detailJson: string
|
||||||
|
summary?: string
|
||||||
|
ipAddress?: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,22 +6,44 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="logs" v-loading="loading" border stripe>
|
<el-table :data="logs" v-loading="loading" border stripe>
|
||||||
<el-table-column prop="createdAt" label="时间" width="180">
|
<el-table-column prop="createdAt" label="时间" width="170">
|
||||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="operator" label="操作者" width="140" />
|
<el-table-column prop="operator" label="操作者" width="120" />
|
||||||
<el-table-column prop="moduleType" label="模块" width="100">
|
<el-table-column prop="moduleType" label="模块" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag size="small">{{ row.moduleType }}</el-tag>
|
<el-tag size="small" :type="moduleTagType(row.moduleType)" effect="plain">{{ row.moduleType }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="action" label="动作" width="180" />
|
<el-table-column label="操作摘要" min-width="300">
|
||||||
<el-table-column prop="resourceType" label="资源类型" width="120" />
|
|
||||||
<el-table-column prop="resourceId" label="资源ID" width="220" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="tenantId" label="租户ID" width="220" show-overflow-tooltip />
|
|
||||||
<el-table-column label="详情" min-width="200">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="detail-text">{{ formatDetail(row.detailJson) }}</span>
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<el-tag size="small" :type="actionTagType(row.action)" effect="dark">{{ row.action }}</el-tag>
|
||||||
|
<span style="font-size:13px;color:#303133">{{ row.summary || row.resourceId || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="tenantId" label="租户ID" width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="ipAddress" label="IP" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-family:monospace;font-size:12px;color:#909399">{{ row.ipAddress || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="详情" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-popover v-if="hasDetail(row.detailJson)" placement="left" :width="360" trigger="click">
|
||||||
|
<template #reference>
|
||||||
|
<el-button link type="primary" size="small">查看</el-button>
|
||||||
|
</template>
|
||||||
|
<div style="max-height:400px;overflow-y:auto">
|
||||||
|
<div v-for="(item, idx) in parseDetail(row.detailJson)" :key="idx"
|
||||||
|
style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #f0f0f0;font-size:13px">
|
||||||
|
<span style="flex-shrink:0;width:80px;color:#909399;font-weight:500">{{ item.key }}</span>
|
||||||
|
<span style="flex:1;word-break:break-all;color:#303133">{{ item.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<span v-else style="color:#c0c4cc">-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -43,14 +65,30 @@ const page = ref(0)
|
|||||||
const size = ref(20)
|
const size = ref(20)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|
||||||
function formatDetail(json: string) {
|
function moduleTagType(m: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||||
if (!json) return '-'
|
return { CONSOLE: 'info', APP: '', SUB_ACCOUNT: 'warning', SERVICE: 'success', APP_SECRET: 'danger', EMAIL_VERIFY: 'info' }[m] as any ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionTagType(a: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||||
|
if (a.startsWith('CREATE')) return 'success'
|
||||||
|
if (a.startsWith('DELETE') || a.startsWith('DISABLE')) return 'danger'
|
||||||
|
if (a.startsWith('RESET') || a.startsWith('REGENERATE')) return 'warning'
|
||||||
|
if (a.startsWith('VIEW')) return 'info'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDetail(json: string) {
|
||||||
|
if (!json) return false
|
||||||
|
try { const o = JSON.parse(json); return o && typeof o === 'object' && Object.keys(o).length > 0 } catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDetail(json: string): { key: string; value: string }[] {
|
||||||
|
if (!json) return []
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(json)
|
const o = JSON.parse(json)
|
||||||
return Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join(', ')
|
if (!o || typeof o !== 'object') return []
|
||||||
} catch {
|
return Object.entries(o).map(([k, v]) => ({ key: k, value: typeof v === 'object' ? JSON.stringify(v) : String(v ?? '') }))
|
||||||
return json
|
} catch { return [{ key: 'raw', value: json }] }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLogs() {
|
async function loadLogs() {
|
||||||
@ -68,11 +106,3 @@ async function loadLogs() {
|
|||||||
|
|
||||||
onMounted(loadLogs)
|
onMounted(loadLogs)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.detail-text {
|
|
||||||
color: #606266;
|
|
||||||
font-size: 13px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@ -9,6 +9,8 @@ export interface TenantOperationLog {
|
|||||||
action: string
|
action: string
|
||||||
operator?: string
|
operator?: string
|
||||||
detailJson?: string
|
detailJson?: string
|
||||||
|
summary?: string
|
||||||
|
ipAddress?: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ async function streamOperation(
|
|||||||
const reader = res.body!.getReader()
|
const reader = res.body!.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
let buf = ''
|
let buf = ''
|
||||||
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
@ -41,6 +42,14 @@ async function streamOperation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (buf) onLine(buf)
|
if (buf) onLine(buf)
|
||||||
|
} catch (e: any) {
|
||||||
|
// Connection drop during RESTART_SELF is expected - don't throw
|
||||||
|
if (e?.name === 'TypeError' || e?.message?.includes('network') || e?.message?.includes('fetch')) {
|
||||||
|
onLine('>>> 连接已中断(服务正在重启)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRunningServices(): Promise<string[]> {
|
export async function getRunningServices(): Promise<string[]> {
|
||||||
|
|||||||
@ -26,21 +26,62 @@
|
|||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<el-table :data="tenantLogs" v-loading="tenantLoading" border stripe>
|
<el-table :data="tenantLogs" v-loading="tenantLoading" border stripe>
|
||||||
<el-table-column prop="createdAt" label="时间" width="180">
|
<el-table-column prop="createdAt" label="时间" width="170" sortable>
|
||||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="moduleType" label="模块" width="140">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag size="small" effect="plain">{{ tenantModuleLabel(row.moduleType) }}</el-tag>
|
<span class="time-text">{{ formatTime(row.createdAt) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="action" label="动作" width="180" />
|
<el-table-column prop="moduleType" label="模块" width="110">
|
||||||
<el-table-column prop="resourceType" label="资源类型" width="140" />
|
|
||||||
<el-table-column prop="resourceId" label="资源ID" min-width="220" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="operator" label="操作人" width="180" />
|
|
||||||
<el-table-column label="详情" min-width="280">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="detail">{{ formatDetail(row.detailJson) }}</span>
|
<el-tag size="small" :type="moduleTagType(row.moduleType)" effect="plain">
|
||||||
|
{{ tenantModuleLabel(row.moduleType) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作摘要" min-width="320">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="summary-cell">
|
||||||
|
<el-tag
|
||||||
|
size="small"
|
||||||
|
:type="actionTagType(row.action)"
|
||||||
|
effect="dark"
|
||||||
|
class="action-tag"
|
||||||
|
>
|
||||||
|
{{ actionLabel(row.action) }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="summary-text">{{ row.summary || generateSummary(row) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="operator" label="操作人" width="120" />
|
||||||
|
<el-table-column prop="ipAddress" label="IP 地址" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="ip-text">{{ row.ipAddress || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="详细数据" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-popover
|
||||||
|
v-if="hasDetail(row.detailJson)"
|
||||||
|
placement="left"
|
||||||
|
:width="380"
|
||||||
|
trigger="click"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<el-button link type="primary" size="small">查看</el-button>
|
||||||
|
</template>
|
||||||
|
<div class="detail-popover">
|
||||||
|
<div
|
||||||
|
v-for="(item, idx) in parseDetail(row.detailJson)"
|
||||||
|
:key="idx"
|
||||||
|
class="detail-row"
|
||||||
|
>
|
||||||
|
<span class="detail-key">{{ item.key }}</span>
|
||||||
|
<span class="detail-value">{{ item.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<span v-else class="no-detail">-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -78,7 +119,7 @@
|
|||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<el-table :data="updateLogs" v-loading="updateLoading" border stripe>
|
<el-table :data="updateLogs" v-loading="updateLoading" border stripe>
|
||||||
<el-table-column prop="createdAt" label="时间" width="180">
|
<el-table-column prop="createdAt" label="时间" width="170">
|
||||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="resourceType" label="资源类型" width="140">
|
<el-table-column prop="resourceType" label="资源类型" width="140">
|
||||||
@ -153,7 +194,7 @@ async function loadApps() {
|
|||||||
updateAppKey.value = apps.value[0].appKey
|
updateAppKey.value = apps.value[0].appKey
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore; empty state will be shown in the selector
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,17 +242,109 @@ function handleUpdateAppChange() {
|
|||||||
loadUpdateLogs()
|
loadUpdateLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Label mappings ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function tenantModuleLabel(moduleType: string) {
|
function tenantModuleLabel(moduleType: string) {
|
||||||
return {
|
return {
|
||||||
CONSOLE: '控制台',
|
CONSOLE: '控制台',
|
||||||
APP: '应用管理',
|
APP: '应用管理',
|
||||||
SUB_ACCOUNT: '子账号管理',
|
SUB_ACCOUNT: '子账号',
|
||||||
SERVICE: '服务管理',
|
SERVICE: '服务管理',
|
||||||
APP_SECRET: '应用密钥',
|
APP_SECRET: '密钥管理',
|
||||||
EMAIL_VERIFY: '邮箱验证',
|
EMAIL_VERIFY: '邮箱验证',
|
||||||
}[moduleType] ?? moduleType
|
}[moduleType] ?? moduleType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moduleTagType(moduleType: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||||
|
return {
|
||||||
|
CONSOLE: 'info',
|
||||||
|
APP: '',
|
||||||
|
SUB_ACCOUNT: 'warning',
|
||||||
|
SERVICE: 'success',
|
||||||
|
APP_SECRET: 'danger',
|
||||||
|
EMAIL_VERIFY: 'info',
|
||||||
|
}[moduleType] as any ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: string): string {
|
||||||
|
return {
|
||||||
|
CREATE_APP: '创建',
|
||||||
|
UPDATE_APP: '编辑',
|
||||||
|
DELETE_APP: '删除',
|
||||||
|
RESET_APP_SECRET: '重置密钥',
|
||||||
|
REQUEST_SECRET_VERIFY: '验证',
|
||||||
|
REVEAL_APP_SECRET: '查看密钥',
|
||||||
|
CREATE_SUB_ACCOUNT: '创建',
|
||||||
|
DISABLE_SUB_ACCOUNT: '禁用',
|
||||||
|
SEND_VERIFY_CODE: '发送验证码',
|
||||||
|
VERIFY_EMAIL: '验证邮箱',
|
||||||
|
DISABLE_SERVICE: '停用',
|
||||||
|
UPDATE_SERVICE_CONFIG: '更新配置',
|
||||||
|
REQUEST_SERVICE_ACTIVATION: '申请开通',
|
||||||
|
REGENERATE_KEY: '重新生成密钥',
|
||||||
|
VIEW_DASHBOARD: '查看',
|
||||||
|
}[action] ?? action
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionTagType(action: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||||
|
if (action.startsWith('CREATE')) return 'success'
|
||||||
|
if (action.startsWith('DELETE')) return 'danger'
|
||||||
|
if (action.startsWith('DISABLE')) return 'danger'
|
||||||
|
if (action.startsWith('RESET') || action.startsWith('REGENERATE')) return 'warning'
|
||||||
|
if (action.startsWith('UPDATE') || action.startsWith('REVEAL')) return ''
|
||||||
|
if (action.startsWith('VIEW')) return 'info'
|
||||||
|
if (action.startsWith('SEND') || action.startsWith('VERIFY') || action.startsWith('REQUEST')) return 'info'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSummary(row: TenantOperationLog): string {
|
||||||
|
const action = actionLabel(row.action)
|
||||||
|
const resource = row.resourceId || ''
|
||||||
|
return `${action} ${resource}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDetail(detailJson?: string): boolean {
|
||||||
|
if (!detailJson) return false
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(detailJson)
|
||||||
|
return parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETAIL_KEY_LABELS: Record<string, string> = {
|
||||||
|
name: '应用名称',
|
||||||
|
packageName: '包名',
|
||||||
|
appKey: 'AppKey',
|
||||||
|
username: '用户名',
|
||||||
|
nickname: '昵称',
|
||||||
|
email: '邮箱',
|
||||||
|
platform: '平台',
|
||||||
|
serviceType: '服务类型',
|
||||||
|
purpose: '用途',
|
||||||
|
applyReason: '申请原因',
|
||||||
|
before: '变更前',
|
||||||
|
after: '变更后',
|
||||||
|
appCount: '应用数',
|
||||||
|
serviceCount: '服务数',
|
||||||
|
subAccountCount: '子账号数',
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDetail(detailJson?: string): { key: string; value: string }[] {
|
||||||
|
if (!detailJson) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(detailJson)
|
||||||
|
if (!parsed || typeof parsed !== 'object') return []
|
||||||
|
return Object.entries(parsed).map(([k, v]) => ({
|
||||||
|
key: DETAIL_KEY_LABELS[k] ?? k,
|
||||||
|
value: typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? ''),
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return [{ key: '原始数据', value: detailJson }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateResourceLabel(resourceType: string) {
|
function updateResourceLabel(resourceType: string) {
|
||||||
return {
|
return {
|
||||||
APP_VERSION: '应用版本',
|
APP_VERSION: '应用版本',
|
||||||
@ -255,7 +388,6 @@ function formatDetail(detailJson?: string) {
|
|||||||
return detailJson
|
return detailJson
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -265,15 +397,40 @@ function formatDetail(detailJson?: string) {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.responsive-toolbar {
|
.responsive-toolbar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.action-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.summary-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #303133;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-text {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
.detail {
|
.detail {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@ -282,17 +439,46 @@ function formatDetail(detailJson?: string) {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-popover {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.detail-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.detail-key {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 80px;
|
||||||
|
color: #909399;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.detail-value {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #303133;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-detail {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.responsive-toolbar {
|
.responsive-toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.responsive-toolbar :deep(.el-select),
|
.responsive-toolbar :deep(.el-select),
|
||||||
.responsive-toolbar :deep(.el-button),
|
.responsive-toolbar :deep(.el-button),
|
||||||
.responsive-toolbar :deep(.el-input-number) {
|
.responsive-toolbar :deep(.el-input-number) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap :deep(.el-table) {
|
.table-wrap :deep(.el-table) {
|
||||||
min-width: 900px;
|
min-width: 900px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -206,8 +206,8 @@
|
|||||||
v-model="showUpdateDialog"
|
v-model="showUpdateDialog"
|
||||||
:title="operationType === 'update' ? '一键更新' : '重置容器'"
|
:title="operationType === 'update' ? '一键更新' : '重置容器'"
|
||||||
width="600px"
|
width="600px"
|
||||||
:close-on-click-modal="!updating"
|
:close-on-click-modal="!updating && !selfRestarting"
|
||||||
:close-on-press-escape="!updating"
|
:close-on-press-escape="!updating && !selfRestarting"
|
||||||
@closed="resetUpdateDialog"
|
@closed="resetUpdateDialog"
|
||||||
>
|
>
|
||||||
<div v-if="!updating && !updateDone && !updateError" style="color:#606266;">
|
<div v-if="!updating && !updateDone && !updateError" style="color:#606266;">
|
||||||
@ -233,7 +233,7 @@
|
|||||||
|
|
||||||
<div v-if="selfRestarting" class="reconnect-tip">
|
<div v-if="selfRestarting" class="reconnect-tip">
|
||||||
<el-icon class="is-loading"><Loading /></el-icon>
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
<span>正在等待服务重启...</span>
|
<span>正在等待服务重启,页面将在服务恢复后自动刷新...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="updateDone" style="margin-top:12px">
|
<div v-if="updateDone" style="margin-top:12px">
|
||||||
@ -497,9 +497,11 @@ async function startOperation() {
|
|||||||
const streamFn = operationType.value === 'update' ? streamSystemUpdate : streamSystemReset
|
const streamFn = operationType.value === 'update' ? streamSystemUpdate : streamSystemReset
|
||||||
const failMsg = operationType.value === 'update' ? '更新失败' : '重置失败'
|
const failMsg = operationType.value === 'update' ? '更新失败' : '重置失败'
|
||||||
|
|
||||||
|
let seenRestartSelf = false
|
||||||
try {
|
try {
|
||||||
await streamFn((line) => {
|
await streamFn((line) => {
|
||||||
if (line === 'RESTART_SELF') {
|
if (line === 'RESTART_SELF') {
|
||||||
|
seenRestartSelf = true
|
||||||
selfRestarting.value = true
|
selfRestarting.value = true
|
||||||
updating.value = false
|
updating.value = false
|
||||||
pollForRecovery()
|
pollForRecovery()
|
||||||
@ -515,29 +517,50 @@ async function startOperation() {
|
|||||||
updateDone.value = true
|
updateDone.value = true
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
// Connection drop during self-restart is expected
|
||||||
|
if (seenRestartSelf || selfRestarting.value) {
|
||||||
if (!selfRestarting.value) {
|
if (!selfRestarting.value) {
|
||||||
|
selfRestarting.value = true
|
||||||
|
updating.value = false
|
||||||
|
pollForRecovery()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
updating.value = false
|
updating.value = false
|
||||||
updateError.value = e?.message ?? failMsg
|
updateError.value = e?.message ?? failMsg
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollForRecovery() {
|
async function pollForRecovery() {
|
||||||
const deadline = Date.now() + 90_000
|
const deadline = Date.now() + 120_000
|
||||||
|
let attempt = 0
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
|
attempt++
|
||||||
await new Promise(r => setTimeout(r, 3000))
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
try {
|
try {
|
||||||
await getDeploymentStatus()
|
await getDeploymentStatus()
|
||||||
selfRestarting.value = false
|
selfRestarting.value = false
|
||||||
updateDone.value = true
|
updateDone.value = true
|
||||||
updateLog.value.push(operationType.value === 'update' ? '>>> tenant-service 已重启,更新完成 ✓' : '>>> tenant-service 已重启,重置完成 ✓')
|
updateLog.value.push(operationType.value === 'update'
|
||||||
|
? '>>> tenant-service 已重启,更新完成 ✓'
|
||||||
|
: '>>> tenant-service 已重启,重置完成 ✓')
|
||||||
|
// Auto-reload after 2s to refresh all page state
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload()
|
||||||
|
}, 2000)
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
// still restarting
|
if (attempt % 5 === 0) {
|
||||||
|
updateLog.value.push(`>>> 等待服务恢复中... (第 ${attempt} 次尝试)`)
|
||||||
|
nextTick(() => {
|
||||||
|
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selfRestarting.value = false
|
selfRestarting.value = false
|
||||||
updateError.value = '等待 tenant-service 重启超时,请手动刷新页面'
|
updateError.value = '等待服务重启超时(2 分钟),请手动刷新页面确认更新结果'
|
||||||
|
updateLog.value.push('>>> 等待超时,请手动刷新页面检查服务状态')
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmt = formatTime
|
const fmt = formatTime
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户