You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1117 lines
45 KiB
1117 lines
45 KiB
<template>
|
|
<div>
|
|
<!-- 列表视图:应用卡片(应用 = 卡片,部署 = 卡片内表格),两层清晰区分 -->
|
|
<div v-show="view === 'list'">
|
|
<div class="flex items-center justify-between mb-5">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-slate-800">应用管理</h2>
|
|
<p class="text-sm text-slate-400 mt-1">
|
|
<span class="chip-app">应用</span> 是一个产品(如 Voitrans);其下挂多条 <span class="chip-dep">部署</span>(各区域一份)。
|
|
卡片 = 应用;卡片内表格 = 该应用的区域部署。
|
|
</p>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm text-white" @click="showProductForm()">+ 添加应用</button>
|
|
</div>
|
|
|
|
<!-- 加载 / 空态 -->
|
|
<div v-if="loading" class="bg-white rounded-xl border border-slate-100 py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div>
|
|
<div v-else-if="!groups.length" class="bg-white rounded-xl border border-slate-100 py-14 text-center text-slate-400">暂无应用,先「添加应用」</div>
|
|
|
|
<!-- 应用卡片列表 -->
|
|
<div v-else class="space-y-4">
|
|
<div v-for="g in groups" :key="g.key" class="app-card" :class="{ orphan: !g.product }">
|
|
<!-- 应用级 header -->
|
|
<div class="app-head" @click="toggle(g.key)">
|
|
<button class="chev">{{ isExpanded(g.key) ? '▾' : '▸' }}</button>
|
|
<div class="app-logo" :class="{ 'orphan-logo': !g.product }">{{ g.product ? (g.product.name || '?').charAt(0).toUpperCase() : '?' }}</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<span class="chip-app">应用</span>
|
|
<span class="app-name">{{ g.product ? g.product.name : '未分组部署' }}</span>
|
|
<span v-if="g.product" class="badge badge-xs" :class="g.product.enabled ? 'badge-success' : 'badge-ghost'">{{ g.product.enabled ? '启用' : '停用' }}</span>
|
|
<span v-if="g.product && g.product.image_name" class="chip-mono">🐳 {{ g.product.image_name }}</span>
|
|
<span class="chip-count">{{ g.deployments.length }} 个部署</span>
|
|
</div>
|
|
<p class="text-xs text-slate-400 mt-0.5 truncate">
|
|
{{ g.product ? (g.product.remark || '—') : '未填所属应用名、或应用已删除的历史部署;编辑部署可归位到某个应用' }}
|
|
</p>
|
|
</div>
|
|
<div v-if="g.product" class="flex gap-1 shrink-0" @click.stop>
|
|
<button class="btn btn-primary btn-xs text-white" @click="showDeployForm(g.product.name)">+ 部署</button>
|
|
<button class="btn btn-ghost btn-xs" @click="showProductForm(g.product)">编辑应用</button>
|
|
<button class="btn btn-ghost btn-xs text-red-500" @click="delProduct(g.product)">删除</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 部署列表(该应用下的各区域部署) -->
|
|
<div v-show="isExpanded(g.key)" class="app-body">
|
|
<div v-if="!g.deployments.length" class="text-center text-slate-300 py-5 text-xs">
|
|
该应用暂无部署<template v-if="g.product">,点右上「+ 部署」添加</template>
|
|
</div>
|
|
<div v-else class="overflow-x-auto">
|
|
<table class="table table-sm text-sm">
|
|
<thead>
|
|
<tr class="text-slate-400">
|
|
<th>部署</th><th>区域</th><th>环境</th><th>状态</th><th>当前版本</th><th>业务库</th><th>启用</th><th class="text-right">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="a in g.deployments" :key="a.id" class="dep-row hover">
|
|
<td>
|
|
<span class="chip-dep">部署</span>
|
|
<span class="font-medium text-slate-700 ml-1">{{ a.name || ('部署#' + a.id) }}</span>
|
|
<span class="text-xs text-slate-400 ml-1 font-mono">#{{ a.id }}</span>
|
|
</td>
|
|
<td>{{ a.region || '—' }}</td>
|
|
<td>
|
|
<select v-model.number="a.env_id" class="select select-bordered select-xs w-36 text-xs" :disabled="envSavingId === a.id" @change="bindEnvRow(a)">
|
|
<option :value="0">未绑定</option>
|
|
<option v-for="e in envs" :key="e.id" :value="e.id">{{ e.name }}</option>
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<span v-if="a.status === 'pending'" class="badge badge-warning badge-sm text-amber-900">待部署</span>
|
|
<span v-else class="badge badge-success badge-sm text-white">已接入</span>
|
|
</td>
|
|
<td>
|
|
<span v-if="isPinnedVer(a.image_tag)" class="chip-mono text-emerald-700">{{ a.image_tag }}</span>
|
|
<span v-else class="text-amber-600 text-xs font-mono" title="未固定版本,升级时建议选明确版本号(如 0.1.1)">{{ a.image_tag || 'latest' }} ⚠</span>
|
|
</td>
|
|
<td class="font-mono text-xs">{{ dbName(a.service_dsn) }}</td>
|
|
<td>
|
|
<span v-if="a.enabled" class="text-green-600">是</span>
|
|
<span v-else class="text-slate-400">否</span>
|
|
</td>
|
|
<td class="text-right">
|
|
<div class="flex gap-1 justify-end">
|
|
<template v-if="a.status !== 'pending'">
|
|
<button class="btn btn-ghost btn-xs text-indigo-600" @click="openAppConfig(a)">服务配置</button>
|
|
<button class="btn btn-ghost btn-xs text-slate-500" @click="showDeployForm(a.app_name, a)">编辑</button>
|
|
<button class="btn btn-ghost btn-xs text-amber-600" @click="openUpgrade(a)">升级</button>
|
|
<button class="btn btn-ghost btn-xs text-emerald-600" @click="openAnalyze(a.id, a.name || ('部署#' + a.id))">分析</button>
|
|
<button class="btn btn-ghost btn-xs text-indigo-500" @click="notifyApp(a.id)">发事件</button>
|
|
</template>
|
|
<button v-else class="btn btn-ghost btn-xs text-amber-600" @click="showDeployForm(a.app_name, a)">编辑</button>
|
|
<button class="btn btn-ghost btn-xs text-red-500" @click="delApp(a.id)">删除</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 应用(产品)表单 -->
|
|
<div v-show="view === 'productForm'">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回</button>
|
|
<h2 class="text-lg font-semibold text-slate-800">{{ pIsEditing ? '编辑应用' : '添加应用' }}</h2>
|
|
</div>
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-xl">
|
|
<form @submit.prevent="saveProduct">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">应用名 *</span></label>
|
|
<input v-model="pForm.name" type="text" class="input input-bordered w-full" required placeholder="如 Voitrans" />
|
|
<span class="text-xs text-slate-400 mt-1">账号作用域按应用名绑定,尽量与统计/账号里一致。</span>
|
|
</div>
|
|
<div class="form-control mt-4">
|
|
<label class="label"><span class="label-text font-medium">默认镜像名</span></label>
|
|
<input v-model="pForm.image_name" type="text" class="input input-bordered w-full" placeholder="voitrans-a11(部署时用,可留空)" />
|
|
</div>
|
|
<div class="form-control mt-4">
|
|
<label class="label"><span class="label-text font-medium">备注</span></label>
|
|
<input v-model="pForm.remark" type="text" class="input input-bordered w-full" />
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-4">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input v-model="pForm.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" />
|
|
<span class="text-sm">启用</span>
|
|
</label>
|
|
</div>
|
|
<div class="flex gap-3 mt-6">
|
|
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
|
|
<button type="submit" class="btn btn-primary text-white">保存</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 部署表单:现有 / 部署目标环境 双模式 -->
|
|
<div v-show="view === 'deployForm'">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回</button>
|
|
<h2 class="text-lg font-semibold text-slate-800">{{ dIsEditing ? '编辑部署' : '添加部署' }}</h2>
|
|
</div>
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-3xl">
|
|
<form @submit.prevent="saveDeploy">
|
|
<!-- 模式切换(仅新增部署时可选;编辑/升级保持原状态)-->
|
|
<div v-if="!dIsEditing" class="tabs tabs-boxed bg-slate-100 w-fit mb-5">
|
|
<a class="tab" :class="dMode === 'existing' ? 'tab-active' : ''" @click="setMode('existing')">添加现有</a>
|
|
<a class="tab" :class="dMode === 'deploy' ? 'tab-active' : ''" @click="setMode('deploy')">部署到目标环境</a>
|
|
</div>
|
|
<p class="text-xs text-slate-400 mb-4" :class="dIsEditing ? '' : '-mt-3'">
|
|
<template v-if="dIsEditing">编辑该区域部署的配置(镜像 Tag / 部署目录 / 环境节点 等);改好后回列表点「升级」执行远程部署(拉新镜像并重启)。</template>
|
|
<template v-else-if="dMode === 'existing'">把「已经部署好的应用区域」加进来:填地址与业务库 DSN,测试通过后保存。</template>
|
|
<template v-else>选一个「部署目标环境」,先建关联并标记<b>待部署</b>;保存后回列表点「升级」执行远程部署。</template>
|
|
</p>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">所属应用</span></label>
|
|
<input :value="dForm.app_name" type="text" class="input input-bordered w-full bg-slate-100" readonly />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">部署名称 *</span></label>
|
|
<input v-model="dForm.name" type="text" class="input input-bordered w-full" required placeholder="如 Voitrans(国内)" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-4 mt-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">区域</span></label>
|
|
<select v-model="dForm.region" class="select select-bordered w-full">
|
|
<option value="">未指定</option>
|
|
<option v-for="r in availableRegions" :key="r" :value="r">{{ r }}</option>
|
|
</select>
|
|
<span class="text-xs text-slate-400 mt-1">已部署的区域不再出现(一个应用每区域一份部署)。</span>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">部署环境{{ dMode === 'deploy' ? ' *' : '' }}</span></label>
|
|
<select v-model.number="dForm.env_id" class="select select-bordered w-full">
|
|
<option :value="0">{{ dMode === 'deploy' ? '请选择目标环境' : '未关联' }}</option>
|
|
<option v-for="e in envs" :key="e.id" :value="e.id">{{ e.name }}({{ e.code || '-' }})</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 部署配置(按应用区分,存到部署记录;升级时改镜像 Tag)。公共项取自所选环境节点。 -->
|
|
<div class="grid grid-cols-3 gap-4 mt-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">部署目录</span></label>
|
|
<input v-model="dForm.deploy_dir" type="text" class="input input-bordered w-full font-mono text-xs" placeholder="/home/work/voitrans-a11" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">镜像名</span></label>
|
|
<input v-model="dForm.image_name" type="text" class="input input-bordered w-full" placeholder="voitrans-a11(默认取应用)" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">镜像 Tag</span></label>
|
|
<input v-model="dForm.image_tag" type="text" class="input input-bordered w-full" placeholder="0.1.1 / latest" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 业务库 DSN:现有模式必填并测试;部署模式可留空、部署后回填 -->
|
|
<div class="form-control mt-4">
|
|
<label class="label"><span class="label-text font-medium">业务库 DSN{{ dMode === 'existing' ? ' *' : '' }}</span></label>
|
|
<input v-model="dForm.service_dsn" type="text" class="input input-bordered w-full font-mono text-xs" placeholder="root:pwd@tcp(host:3306)/earphone?charset=utf8mb4&parseTime=True&loc=Local" @input="resetTest" />
|
|
<span class="text-xs text-slate-400 mt-1">用于读取业务数据(重置设备码时清 userdevice/product_stat)</span>
|
|
</div>
|
|
|
|
<!-- 现有模式:应用地址 + 测试 -->
|
|
<template v-if="dMode === 'existing'">
|
|
<div class="form-control mt-4">
|
|
<label class="label"><span class="label-text font-medium">应用地址 *</span></label>
|
|
<input v-model="dForm.base_url" type="text" class="input input-bordered w-full" placeholder="https://api.xxx.com/" @input="resetTest" />
|
|
<span class="text-xs text-slate-400 mt-1">用于通知应用业务服务事件(如配置更新)</span>
|
|
</div>
|
|
<div v-show="testResult.show" class="mt-4 text-sm">
|
|
<div :class="testResult.apiOk ? 'text-green-700' : 'text-red-600'">
|
|
<span :class="testResult.apiOk ? 'text-green-600' : 'text-red-500'">{{ testResult.apiOk ? '✓' : '✗' }}</span>
|
|
应用地址:{{ testResult.apiMsg }}
|
|
</div>
|
|
<div :class="testResult.dbOk ? 'text-green-700' : 'text-red-600'">
|
|
<span :class="testResult.dbOk ? 'text-green-600' : 'text-red-500'">{{ testResult.dbOk ? '✓' : '✗' }}</span>
|
|
业务库:{{ testResult.dbMsg }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="form-control mt-4">
|
|
<label class="label"><span class="label-text font-medium">备注</span></label>
|
|
<input v-model="dForm.remark" type="text" class="input input-bordered w-full" />
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-4">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input v-model="dForm.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" />
|
|
<span class="text-sm">启用</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="flex gap-3 mt-6">
|
|
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
|
|
<button v-if="dMode === 'existing'" type="button" class="btn btn-outline" :disabled="testing" @click="testConn">{{ testing ? '测试中...' : '测试连接' }}</button>
|
|
<button type="submit" class="btn btn-primary text-white" :disabled="!dIsEditing && dMode === 'existing' && !testPassed">保存</button>
|
|
</div>
|
|
<p v-if="!dIsEditing && dMode === 'existing'" class="text-xs text-slate-400 mt-2">需先「测试连接」通过(应用地址可达 + 业务库可连)才能保存。</p>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 历史数据分析弹窗 -->
|
|
<dialog ref="analyzeModal" class="modal">
|
|
<div class="modal-box">
|
|
<h3 class="font-bold text-lg">分析历史数据</h3>
|
|
<p class="text-sm text-slate-500 mt-1">{{ az.appLabel }}</p>
|
|
<div class="text-xs text-amber-700 mt-3 bg-amber-50 border border-amber-100 rounded-lg p-3 leading-relaxed">
|
|
扫描该应用业务库,按 <b>天 + 产品 + 区域</b> 生成历史统计(新增用户 / 订单 / 资源发放·使用 / 激活绑定设备),汇入 console 主库。<br>
|
|
· <b>分析前会先清理该应用+区域在所选区间内的旧统计行</b>,避免数据重叠。<br>
|
|
· 登录、活跃用户、AI对话等无每日原始记录,历史无法还原(由实时管道往后采集)。<br>
|
|
· 仅对实时管道上线前的区间运行。留空 = 全部历史。大库可能耗时。
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3 mt-4">
|
|
<div><label class="text-xs text-slate-500">起始日(含)</label><input v-model="az.start" type="date" class="input input-bordered input-sm w-full" /></div>
|
|
<div><label class="text-xs text-slate-500">结束日(含)</label><input v-model="az.end" type="date" class="input input-bordered input-sm w-full" /></div>
|
|
</div>
|
|
<div v-show="az.resultShow" class="text-sm mt-3" :class="az.resultClass">
|
|
<!-- 进度态 -->
|
|
<div v-if="az.mode === 'progress'" class="bg-slate-50 border border-slate-200 rounded-lg p-3">
|
|
<div class="flex justify-between items-center text-xs mb-1.5">
|
|
<span class="text-slate-600">
|
|
<template v-if="az.curName">
|
|
<span class="loading loading-spinner loading-xs"></span> 正在:{{ az.curName }} ({{ az.curIndex }}/{{ az.curTotal }})
|
|
</template>
|
|
<template v-else>准备中...</template>
|
|
</span>
|
|
<span class="font-mono text-slate-400">{{ az.pct }}%</span>
|
|
</div>
|
|
<progress class="progress progress-primary w-full h-2" :value="az.pct" max="100"></progress>
|
|
<div class="mt-2 text-xs space-y-0.5">
|
|
<div v-for="(nm, i) in az.steps" :key="i" :class="i === az.curIndex - 1 ? 'text-slate-600' : 'text-green-600'">
|
|
<template v-if="i === az.curIndex - 1"><span class="loading loading-spinner loading-xs"></span> {{ nm }}</template>
|
|
<template v-else>✓ {{ nm }}</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- 错误态 -->
|
|
<div v-else-if="az.mode === 'error'" class="text-red-600">✗ {{ az.errMsg }}</div>
|
|
<!-- 完成报告 -->
|
|
<div v-else-if="az.mode === 'done' && az.report" class="bg-green-50 border border-green-100 rounded-lg p-3">
|
|
<div class="font-semibold text-green-700 mb-2">✓ 分析完成</div>
|
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-600 mb-2">
|
|
<div>应用:<b>{{ az.report.app_name || '' }}</b></div>
|
|
<div>区域:{{ az.report.region || '未指定' }}</div>
|
|
<div>区间:{{ (az.report.start_day || '不限') + ' ~ ' + (az.report.end_day || '不限') }}</div>
|
|
<div>清理旧行:{{ az.report.cleared_rows || 0 }}</div>
|
|
<div>写入天数:<b>{{ az.report.days_written || 0 }}</b></div>
|
|
<div>写入行数:{{ az.report.rows_written || 0 }}</div>
|
|
</div>
|
|
<div class="border-t border-green-100 pt-2 text-xs">
|
|
<div v-for="(s, i) in (az.report.steps || [])" :key="i" class="flex justify-between py-0.5">
|
|
<span class="text-slate-500">{{ s.name }}</span>
|
|
<span class="font-mono text-slate-700">{{ (s.groups || 0) + ' 组' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-action">
|
|
<button class="btn btn-ghost btn-sm" @click="closeAnalyze">关闭</button>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="az.running" @click="runAnalyze">{{ az.btnText }}</button>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
</dialog>
|
|
|
|
<!-- 升级(远程部署)弹窗:确认目标 + 可改 tag,确认后 SSH 执行并实时回显日志 -->
|
|
<dialog ref="upgradeModal" class="modal">
|
|
<div class="modal-box max-w-2xl">
|
|
<h3 class="font-bold text-lg">升级部署</h3>
|
|
<p class="text-sm text-slate-500 mt-1">{{ up.label }}</p>
|
|
|
|
<div class="text-xs text-amber-700 mt-3 bg-amber-50 border border-amber-100 rounded-lg p-3 leading-relaxed">
|
|
将 <b>SSH 连接到该部署绑定的环境节点</b>,进入部署目录执行:<span class="font-mono">docker login → compose pull → up -d 重启 → 清理旧镜像</span>。<br>
|
|
· 需服务器上已维护好 <span class="font-mono">docker-compose.yml / .env / confs</span>(本操作不上传真实配置)。<br>
|
|
· 会<b>重启该部署的容器</b>,请确认无误后再执行。
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-2 mt-4 text-xs">
|
|
<div class="flex"><span class="w-20 text-slate-400 shrink-0">环境节点</span><span :class="up.envName ? 'font-medium text-slate-700' : 'font-medium text-red-500'">{{ up.envName || '未绑定 —— 请先在「编辑」里选择环境节点' }}</span></div>
|
|
<div class="flex"><span class="w-20 text-slate-400 shrink-0">部署目录</span><span :class="up.deployDir ? 'font-mono text-slate-700' : 'font-medium text-red-500'">{{ up.deployDir || '未填 —— 请先在「编辑」里填写远端部署目录' }}</span></div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<label class="text-xs text-slate-500">镜像版本 Tag</label>
|
|
<button type="button" class="text-xs text-indigo-500 hover:underline" :disabled="up.running"
|
|
@click="up.tagMode = up.tagMode === 'list' ? 'custom' : 'list'">
|
|
{{ up.tagMode === 'list' ? '手动输入…' : '从仓库选择' }}
|
|
</button>
|
|
</div>
|
|
<!-- 从镜像仓库拉取的可用版本,下拉选择 -->
|
|
<div v-if="up.tagMode === 'list'" class="flex gap-2">
|
|
<select v-model="up.tag" class="select select-bordered select-sm flex-1 font-mono" :disabled="up.running || up.tagsLoading">
|
|
<option value="">{{ up.tagsLoading ? '加载可用版本中…' : '请选择版本' }}</option>
|
|
<option v-if="up.tag && !up.tags.includes(up.tag)" :value="up.tag">{{ up.tag }}(当前记录)</option>
|
|
<option v-for="t in up.tags" :key="t" :value="t">{{ t }}{{ t === 'latest' ? ' · 不固定,不推荐' : '' }}</option>
|
|
</select>
|
|
<button type="button" class="btn btn-ghost btn-sm" :disabled="up.running || up.tagsLoading" @click="loadUpgradeTags">刷新</button>
|
|
</div>
|
|
<!-- 手动输入兜底(仓库拉取失败或想指定任意 tag 时用) -->
|
|
<input v-else v-model="up.tag" type="text" class="input input-bordered input-sm w-full font-mono" placeholder="0.1.1" :disabled="up.running" />
|
|
<p v-if="up.tagsError" class="text-xs text-amber-600 mt-1">拉取仓库版本失败:{{ up.tagsError }}(可点上方「手动输入」直接填版本号,或点「刷新」重试)</p>
|
|
<p v-else-if="up.tag === 'latest' || up.tag === ''" class="text-xs text-amber-600 mt-1">⚠ 未固定版本,升级后列表无法体现具体版本号,建议选明确版本(如 0.1.1)。</p>
|
|
</div>
|
|
|
|
<!-- 日志流 -->
|
|
<div v-show="up.show" class="mt-3">
|
|
<div class="flex items-center gap-2 text-xs mb-1">
|
|
<span v-if="up.running" class="loading loading-spinner loading-xs text-amber-500"></span>
|
|
<span v-else-if="up.ok" class="text-green-600 font-semibold">✓ 部署完成</span>
|
|
<span v-else class="text-red-600 font-semibold">✗ 部署失败</span>
|
|
<span class="text-slate-400">{{ up.running ? '执行中…' : '' }}</span>
|
|
</div>
|
|
<pre ref="upLogBox" class="bg-slate-900 text-slate-100 text-[11px] leading-relaxed rounded-lg p-3 h-64 overflow-auto font-mono whitespace-pre-wrap">{{ up.log || '(等待输出…)' }}</pre>
|
|
</div>
|
|
|
|
<div class="modal-action">
|
|
<button class="btn btn-ghost btn-sm" :disabled="up.running" @click="closeUpgrade">关闭</button>
|
|
<button class="btn btn-warning btn-sm" :disabled="up.running" @click="runUpgrade">{{ up.btnText }}</button>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button :disabled="up.running">close</button></form>
|
|
</dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { useAuthStore } from '~/stores/auth'
|
|
|
|
definePageMeta({ title: '应用管理' })
|
|
|
|
interface Product {
|
|
id: number
|
|
name: string
|
|
image_name?: string
|
|
remark: string
|
|
enabled: boolean
|
|
}
|
|
|
|
interface Deployment {
|
|
id: number
|
|
app_name: string
|
|
status: string
|
|
name: string
|
|
region: string
|
|
env_id: number
|
|
base_url: string
|
|
service_dsn: string
|
|
deploy_dir: string
|
|
image_name: string
|
|
image_tag: string
|
|
remark: string
|
|
enabled: boolean
|
|
}
|
|
|
|
interface EnvOption {
|
|
id: number
|
|
name: string
|
|
code: string
|
|
}
|
|
|
|
interface ReportStep {
|
|
name: string
|
|
groups: number
|
|
}
|
|
|
|
interface AnalyzeReport {
|
|
app_name: string
|
|
region: string
|
|
start_day: number
|
|
end_day: number
|
|
cleared_rows: number
|
|
days_written: number
|
|
rows_written: number
|
|
steps: ReportStep[]
|
|
}
|
|
|
|
const { consoleApi } = useApi()
|
|
const { success, error } = useToast()
|
|
const auth = useAuthStore()
|
|
const router = useRouter()
|
|
|
|
const view = ref<'list' | 'productForm' | 'deployForm'>('list')
|
|
const products = ref<Product[]>([])
|
|
const apps = ref<Deployment[]>([])
|
|
const envs = ref<EnvOption[]>([])
|
|
const loading = ref(false)
|
|
const envSavingId = ref(0)
|
|
const expanded = reactive<Record<string, boolean>>({})
|
|
const ORPHAN_KEY = '__orphan__'
|
|
|
|
const regionOptions = ['中国', '美国', '东亚', '东南亚', '南亚', '中东', '欧洲', '俄罗斯', '非洲', '南美', '北美', '大洋洲', '巴西', '印度']
|
|
|
|
// ---- 分组:按 app_name(应用名)把部署归到应用名下,未匹配的进「未分组」 ----
|
|
const groups = computed(() => {
|
|
const byName = new Map<string, Deployment[]>()
|
|
for (const d of apps.value) {
|
|
const name = d.app_name || ''
|
|
if (!byName.has(name)) byName.set(name, [])
|
|
byName.get(name)!.push(d)
|
|
}
|
|
const known = new Set(products.value.map((p) => p.name))
|
|
const res = products.value.map((p) => ({ key: p.name, product: p as Product | null, deployments: byName.get(p.name) || [] }))
|
|
const orphan: Deployment[] = []
|
|
for (const [name, ds] of byName) {
|
|
if (name === '' || !known.has(name)) orphan.push(...ds)
|
|
}
|
|
if (orphan.length) res.push({ key: ORPHAN_KEY, product: null, deployments: orphan })
|
|
return res
|
|
})
|
|
|
|
function isExpanded(key: string) {
|
|
return expanded[key] !== false // 默认展开:直接呈现「应用 → 部署」层级,点卡片头可折叠
|
|
}
|
|
function toggle(key: string) {
|
|
expanded[key] = expanded[key] === false ? true : false
|
|
}
|
|
|
|
function envName(id?: number) {
|
|
if (!id) return '-'
|
|
const e = envs.value.find((x) => x.id === id)
|
|
return e ? e.name : '#' + id
|
|
}
|
|
|
|
function dbName(dsn?: string) {
|
|
const m = (dsn || '').match(/\/([^/?]+)\?/)
|
|
return m ? m[1] : '-'
|
|
}
|
|
|
|
// 是否已固定到明确版本(非空、非 latest)→ 列表用绿色 chip 显示;否则标黄提示。
|
|
function isPinnedVer(tag?: string) {
|
|
const t = (tag || '').trim()
|
|
return !!t && t !== 'latest'
|
|
}
|
|
|
|
async function loadAll() {
|
|
loading.value = true
|
|
try {
|
|
const [ps, ds, es] = await Promise.all([
|
|
consoleApi<Product[]>('products/list', {}),
|
|
consoleApi<Deployment[]>('apps/list', {}),
|
|
consoleApi<EnvOption[]>('envs/list', {}),
|
|
])
|
|
products.value = ps ?? []
|
|
apps.value = ds ?? []
|
|
envs.value = es ?? []
|
|
} catch (e: any) {
|
|
error(e?.message ?? '获取应用列表失败')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function showList() {
|
|
view.value = 'list'
|
|
loadAll()
|
|
}
|
|
|
|
// ---- 应用(产品)表单 ----
|
|
const pIsEditing = ref(false)
|
|
const pForm = reactive({ id: 0, name: '', image_name: '', remark: '', enabled: true })
|
|
|
|
function showProductForm(p?: Product) {
|
|
if (p) {
|
|
pIsEditing.value = true
|
|
pForm.id = p.id
|
|
pForm.name = p.name || ''
|
|
pForm.image_name = p.image_name || ''
|
|
pForm.remark = p.remark || ''
|
|
pForm.enabled = !!p.enabled
|
|
} else {
|
|
pIsEditing.value = false
|
|
pForm.id = 0
|
|
pForm.name = ''
|
|
pForm.image_name = ''
|
|
pForm.remark = ''
|
|
pForm.enabled = true
|
|
}
|
|
view.value = 'productForm'
|
|
}
|
|
|
|
async function saveProduct() {
|
|
const data: Record<string, unknown> = {
|
|
name: pForm.name,
|
|
image_name: pForm.image_name,
|
|
remark: pForm.remark,
|
|
enabled: pForm.enabled,
|
|
}
|
|
let path = 'products/add'
|
|
if (pIsEditing.value && pForm.id) {
|
|
data.id = Number(pForm.id)
|
|
path = 'products/update'
|
|
}
|
|
try {
|
|
await consoleApi(path, data)
|
|
success('保存成功')
|
|
showList()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '失败')
|
|
}
|
|
}
|
|
|
|
function delProduct(p: Product) {
|
|
if (!window.confirm(`删除应用「${p.name}」?(其下若有区域部署将被拒绝)`)) return
|
|
consoleApi('products/del', { id: p.id })
|
|
.then(() => { success('已删除'); loadAll() })
|
|
.catch((e: any) => error(e?.message ?? '失败'))
|
|
}
|
|
|
|
// ---- 部署表单(现有 / 部署目标环境)----
|
|
const dIsEditing = ref(false)
|
|
const dMode = ref<'existing' | 'deploy'>('existing')
|
|
const dForm = reactive({ id: 0, app_name: '', name: '', region: '', env_id: 0, base_url: '', service_dsn: '', deploy_dir: '', image_name: '', image_tag: '', remark: '', enabled: true })
|
|
|
|
// productImage 取某应用的默认镜像名(新增部署时预填)。
|
|
function productImage(name?: string) {
|
|
if (!name) return ''
|
|
const p = products.value.find((x) => x.name === name)
|
|
return p?.image_name || ''
|
|
}
|
|
|
|
// availableRegions 该应用还没部署过的区域(已部署的区域不可再部署;编辑时保留当前区域)。
|
|
const availableRegions = computed(() => {
|
|
const used = new Set(
|
|
apps.value
|
|
.filter((d) => d.app_name === dForm.app_name && d.id !== dForm.id)
|
|
.map((d) => d.region)
|
|
.filter(Boolean),
|
|
)
|
|
return regionOptions.filter((r) => !used.has(r))
|
|
})
|
|
|
|
const testPassed = ref(false)
|
|
const testing = ref(false)
|
|
const testResult = reactive({ show: false, apiOk: false, apiMsg: '', dbOk: false, dbMsg: '' })
|
|
|
|
function resetTest() {
|
|
testPassed.value = false
|
|
testResult.show = false
|
|
testResult.apiMsg = ''
|
|
testResult.dbMsg = ''
|
|
}
|
|
|
|
function setMode(m: 'existing' | 'deploy') {
|
|
dMode.value = m
|
|
resetTest()
|
|
}
|
|
|
|
function showDeployForm(appName?: string, a?: Deployment) {
|
|
if (a) {
|
|
dIsEditing.value = true
|
|
dMode.value = a.status === 'pending' ? 'deploy' : 'existing'
|
|
dForm.id = a.id
|
|
dForm.app_name = a.app_name || appName || ''
|
|
dForm.name = a.name || ''
|
|
dForm.region = a.region || ''
|
|
dForm.env_id = a.env_id || 0
|
|
dForm.base_url = a.base_url || ''
|
|
dForm.service_dsn = a.service_dsn || ''
|
|
dForm.deploy_dir = a.deploy_dir || ''
|
|
dForm.image_name = a.image_name || ''
|
|
dForm.image_tag = a.image_tag || ''
|
|
dForm.remark = a.remark || ''
|
|
dForm.enabled = !!a.enabled
|
|
} else {
|
|
dIsEditing.value = false
|
|
dMode.value = 'existing'
|
|
dForm.id = 0
|
|
dForm.app_name = appName || ''
|
|
dForm.name = ''
|
|
dForm.region = ''
|
|
dForm.env_id = 0
|
|
dForm.base_url = ''
|
|
dForm.service_dsn = ''
|
|
dForm.deploy_dir = ''
|
|
dForm.image_name = productImage(appName)
|
|
dForm.image_tag = ''
|
|
dForm.remark = ''
|
|
dForm.enabled = true
|
|
}
|
|
resetTest()
|
|
view.value = 'deployForm'
|
|
}
|
|
|
|
async function testConn() {
|
|
const baseUrl = dForm.base_url.trim()
|
|
const serviceDsn = dForm.service_dsn.trim()
|
|
if (!baseUrl || !serviceDsn) {
|
|
error('请先填应用地址和业务库 DSN')
|
|
return
|
|
}
|
|
testing.value = true
|
|
try {
|
|
const d = await consoleApi<{ ok: boolean; api_ok: boolean; api_msg: string; db_ok: boolean; db_msg: string }>('apps/test', { base_url: baseUrl, service_dsn: serviceDsn })
|
|
testResult.apiOk = !!d.api_ok
|
|
testResult.apiMsg = d.api_msg
|
|
testResult.dbOk = !!d.db_ok
|
|
testResult.dbMsg = d.db_msg
|
|
testResult.show = true
|
|
testPassed.value = !!d.ok
|
|
if (testPassed.value) success('检测通过,可以保存')
|
|
else error('检测未通过')
|
|
} catch (e: any) {
|
|
error(e?.message ?? '测试失败')
|
|
} finally {
|
|
testing.value = false
|
|
}
|
|
}
|
|
|
|
async function saveDeploy() {
|
|
if (!dForm.app_name) {
|
|
error('请选择所属应用')
|
|
return
|
|
}
|
|
if (!dForm.name.trim()) {
|
|
error('请填部署名称')
|
|
return
|
|
}
|
|
if (dMode.value === 'existing' && !testPassed.value) {
|
|
error('请先「测试连接」通过再保存')
|
|
return
|
|
}
|
|
if (dMode.value === 'deploy' && !dForm.env_id) {
|
|
error('请选择部署目标环境')
|
|
return
|
|
}
|
|
const data: Record<string, unknown> = {
|
|
app_name: dForm.app_name,
|
|
status: dMode.value === 'deploy' ? 'pending' : 'existing',
|
|
name: dForm.name,
|
|
region: dForm.region,
|
|
env_id: Number(dForm.env_id) || 0,
|
|
base_url: dMode.value === 'deploy' ? '' : dForm.base_url,
|
|
service_dsn: dForm.service_dsn,
|
|
deploy_dir: dForm.deploy_dir,
|
|
image_name: dForm.image_name,
|
|
image_tag: dForm.image_tag,
|
|
remark: dForm.remark,
|
|
enabled: dForm.enabled,
|
|
}
|
|
let path = 'apps/add'
|
|
if (dIsEditing.value && dForm.id) {
|
|
data.id = Number(dForm.id)
|
|
path = 'apps/update'
|
|
}
|
|
try {
|
|
await consoleApi(path, data)
|
|
success('保存成功')
|
|
showList()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '失败')
|
|
}
|
|
}
|
|
|
|
function openAppConfig(a: Deployment) {
|
|
router.push({ path: '/appconfig', query: { id: a.id, name: a.name || ('部署#' + a.id), region: a.region || '', app_name: a.app_name || '' } })
|
|
}
|
|
|
|
// bindEnvRow 内联切换某部署的部署环境(含未绑定的历史数据);失败则回滚并重拉。
|
|
async function bindEnvRow(a: Deployment) {
|
|
envSavingId.value = a.id
|
|
try {
|
|
await consoleApi('apps/bindenv', { id: a.id, env_id: Number(a.env_id) || 0 })
|
|
success('部署环境已更新')
|
|
} catch (e: any) {
|
|
error(e?.message ?? '切换环境失败')
|
|
loadAll() // 回滚为服务器状态
|
|
} finally {
|
|
envSavingId.value = 0
|
|
}
|
|
}
|
|
|
|
function delApp(id: number) {
|
|
if (!window.confirm(`删除部署 #${id} ?(不影响应用自身的库)`)) return
|
|
consoleApi('apps/del', { id })
|
|
.then(() => { success('已删除'); loadAll() })
|
|
.catch((e: any) => error(e?.message ?? '失败'))
|
|
}
|
|
|
|
// ---- 历史数据分析(沿用原逻辑)----
|
|
const analyzeModal = ref<HTMLDialogElement | null>(null)
|
|
const az = reactive({
|
|
appId: 0,
|
|
appLabel: '',
|
|
start: '',
|
|
end: '',
|
|
resultShow: false,
|
|
resultClass: '',
|
|
mode: '' as '' | 'progress' | 'error' | 'done',
|
|
steps: [] as string[],
|
|
curIndex: 0,
|
|
curTotal: 0,
|
|
curName: '',
|
|
pct: 0,
|
|
errMsg: '',
|
|
report: null as AnalyzeReport | null,
|
|
running: false,
|
|
btnText: '开始分析',
|
|
})
|
|
|
|
function azYmd(s: string) {
|
|
return parseInt((s || '').replace(/-/g, '')) || 0
|
|
}
|
|
|
|
function openAnalyze(id: number, name: string) {
|
|
az.appId = id
|
|
az.appLabel = '部署:' + name + ' (#' + id + ')'
|
|
az.start = ''
|
|
az.end = ''
|
|
az.resultShow = false
|
|
az.mode = ''
|
|
az.report = null
|
|
az.errMsg = ''
|
|
az.steps = []
|
|
az.curIndex = 0
|
|
az.curTotal = 0
|
|
az.curName = ''
|
|
az.pct = 0
|
|
az.running = false
|
|
az.btnText = '开始分析'
|
|
analyzeModal.value?.showModal()
|
|
}
|
|
|
|
function closeAnalyze() {
|
|
analyzeModal.value?.close()
|
|
}
|
|
|
|
function azUpdateProgress(ev: { index: number; total: number; name: string }) {
|
|
az.steps[ev.index - 1] = ev.name
|
|
az.pct = Math.round((ev.index / (ev.total + 1)) * 100)
|
|
az.curIndex = ev.index
|
|
az.curTotal = ev.total
|
|
az.curName = ev.name
|
|
}
|
|
|
|
async function runAnalyze() {
|
|
const name = az.appLabel
|
|
const s = az.start || '最早'
|
|
const e = az.end || '至今'
|
|
if (!window.confirm(`将先删除【${name}】在 ${s} ~ ${e} 区间的历史统计数据,然后重新分析写入。\n\n确认继续?`)) return
|
|
|
|
az.running = true
|
|
az.btnText = '分析中...'
|
|
az.resultShow = true
|
|
az.resultClass = ''
|
|
az.mode = 'progress'
|
|
az.steps = []
|
|
az.curIndex = 0
|
|
az.curTotal = 0
|
|
az.curName = ''
|
|
az.pct = 0
|
|
az.report = null
|
|
az.errMsg = ''
|
|
|
|
const body = { app_id: az.appId, start_day: azYmd(az.start), end_day: azYmd(az.end) }
|
|
|
|
let doneReport: AnalyzeReport | null = null
|
|
let errMsg: string | null = null
|
|
let needLogout = false
|
|
|
|
function handleLine(line: string) {
|
|
line = (line || '').trim()
|
|
if (!line) return
|
|
let ev: any
|
|
try { ev = JSON.parse(line) } catch (_) { return }
|
|
if (ev.code === 18) { needLogout = true; return }
|
|
if (ev.code !== undefined && ev.code !== 0) { errMsg = ev.msg || '请求被拒绝'; return }
|
|
if (ev.type === 'progress') { azUpdateProgress(ev) }
|
|
else if (ev.type === 'done') { doneReport = ev.report }
|
|
else if (ev.type === 'error') { errMsg = ev.msg }
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch('/console/api/apps/analyze', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': auth.token },
|
|
body: JSON.stringify(body),
|
|
})
|
|
const reader = resp.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buf = ''
|
|
while (true) {
|
|
const r = await reader.read()
|
|
if (r.done) break
|
|
buf += decoder.decode(r.value, { stream: true })
|
|
const lines = buf.split('\n')
|
|
buf = lines.pop() as string
|
|
for (let i = 0; i < lines.length; i++) handleLine(lines[i] as string)
|
|
}
|
|
handleLine(buf)
|
|
} catch (ex: any) {
|
|
errMsg = '网络异常: ' + ex.message
|
|
}
|
|
|
|
if (needLogout) { auth.logout(); router.push('/'); return }
|
|
az.running = false
|
|
az.btnText = '重新分析'
|
|
if (errMsg) {
|
|
az.resultClass = 'text-red-600'
|
|
az.mode = 'error'
|
|
az.errMsg = errMsg
|
|
return
|
|
}
|
|
if (!doneReport) {
|
|
az.resultClass = 'text-red-600'
|
|
az.mode = 'error'
|
|
az.errMsg = '分析中断,未收到完成事件'
|
|
return
|
|
}
|
|
az.resultClass = ''
|
|
az.mode = 'done'
|
|
az.report = doneReport
|
|
success('分析完成')
|
|
loadAll()
|
|
}
|
|
|
|
function notifyApp(id: number) {
|
|
if (!window.confirm(`向部署 #${id} 发送一条配置 reload 事件?`)) return
|
|
consoleApi<{ subject?: string }>('config/notify', { app_id: id, type: 'all', action: 'reload' })
|
|
.then((d: { subject?: string }) => { success('已发送 → ' + (d && d.subject)) })
|
|
.catch((e: any) => error(e?.message ?? '失败'))
|
|
}
|
|
|
|
// ---- 升级(远程部署):SSH 到部署环境执行 docker compose 拉新镜像并重启,NDJSON 流式回显日志 ----
|
|
const upgradeModal = ref<HTMLDialogElement | null>(null)
|
|
const upLogBox = ref<HTMLElement | null>(null)
|
|
const up = reactive({
|
|
id: 0,
|
|
label: '',
|
|
envName: '',
|
|
deployDir: '',
|
|
tag: '',
|
|
tags: [] as string[], // 从仓库拉取的可用版本
|
|
tagMode: 'list' as 'list' | 'custom',
|
|
tagsLoading: false,
|
|
tagsError: '',
|
|
log: '',
|
|
show: false,
|
|
running: false,
|
|
ok: false,
|
|
canRun: false,
|
|
btnText: '开始升级',
|
|
})
|
|
|
|
function openUpgrade(a: Deployment) {
|
|
up.id = a.id
|
|
up.label = '部署:' + (a.name || ('部署#' + a.id)) + ' (#' + a.id + ')' + (a.region ? ' · ' + a.region : '')
|
|
up.envName = a.env_id ? envName(a.env_id) : ''
|
|
up.deployDir = a.deploy_dir || ''
|
|
up.tag = a.image_tag || ''
|
|
up.tags = []
|
|
up.tagMode = 'list'
|
|
up.tagsError = ''
|
|
up.log = ''
|
|
up.show = false
|
|
up.running = false
|
|
up.ok = false
|
|
up.canRun = !!(a.env_id && (a.deploy_dir || '').trim())
|
|
up.btnText = '开始升级'
|
|
upgradeModal.value?.showModal()
|
|
if (up.canRun) loadUpgradeTags() // 弹窗弹出后异步拉可用版本;缺环境/目录时无从查,跳过
|
|
}
|
|
|
|
// 从镜像仓库拉取该部署镜像的可用 tag,填充升级弹窗下拉。失败则切到手动输入。
|
|
async function loadUpgradeTags() {
|
|
if (!up.id) return
|
|
up.tagsLoading = true
|
|
up.tagsError = ''
|
|
try {
|
|
const r = await consoleApi<{ repo: string; current: string; tags: string[] }>('apps/tags', { id: up.id })
|
|
up.tags = r.tags || []
|
|
up.tagMode = 'list'
|
|
// 预选:优先当前记录的版本;否则第一个非 latest 的明确版本,引导选版本号而非 latest
|
|
if (!up.tag || !up.tags.includes(up.tag)) {
|
|
up.tag = (up.tag && up.tags.includes(up.tag)) ? up.tag : (up.tags.find((t) => t !== 'latest') || up.tag || '')
|
|
}
|
|
} catch (e: any) {
|
|
// 失败不清空 tags、不强制切模式:保留「当前记录版本」仍可选,只提示错误,避免看着像数据全没了
|
|
up.tagsError = e?.message ?? '拉取失败'
|
|
} finally {
|
|
up.tagsLoading = false
|
|
}
|
|
}
|
|
|
|
function closeUpgrade() {
|
|
if (up.running) return
|
|
upgradeModal.value?.close()
|
|
}
|
|
|
|
async function runUpgrade() {
|
|
if (up.running) return
|
|
if (!up.canRun) {
|
|
const miss: string[] = []
|
|
if (!up.envName) miss.push('绑定环境节点')
|
|
if (!up.deployDir) miss.push('填写部署目录')
|
|
window.alert(`无法升级:请先关闭本弹窗,点该部署的「编辑」完成 —— ${miss.join('、')},保存后再来升级。`)
|
|
return
|
|
}
|
|
const tag = (up.tag || '').trim()
|
|
if (!tag || tag === 'latest') {
|
|
if (!window.confirm(`未选择明确版本号(当前 = ${tag || '空 → 后端回退 latest'})。\nlatest 是可变 tag,升级后列表无法体现具体版本号,确定继续?`)) return
|
|
}
|
|
if (!window.confirm(`确认升级【${up.label}】?\n目标版本:${tag || 'latest'}\n将 SSH 连到「${up.envName}」,在 ${up.deployDir} 执行 docker compose 拉新镜像并重启容器。`)) return
|
|
|
|
up.running = true
|
|
up.show = true
|
|
up.ok = false
|
|
up.log = ''
|
|
up.btnText = '升级中…'
|
|
|
|
let hadError: string | null = null
|
|
let needLogout = false
|
|
|
|
function append(s: string) {
|
|
up.log += (up.log ? '\n' : '') + s
|
|
nextTick(() => { if (upLogBox.value) upLogBox.value.scrollTop = upLogBox.value.scrollHeight })
|
|
}
|
|
function handleLine(line: string) {
|
|
line = (line || '').trim()
|
|
if (!line) return
|
|
let ev: any
|
|
try { ev = JSON.parse(line) } catch (_) { return }
|
|
if (ev.code === 18) { needLogout = true; return }
|
|
if (ev.code !== undefined && ev.code !== 0 && ev.type === undefined) { hadError = ev.msg || '请求被拒绝'; return }
|
|
if (ev.type === 'log') { append(ev.line || '') }
|
|
else if (ev.type === 'error') { hadError = ev.msg; append('✗ ' + (ev.msg || '')) }
|
|
// ev.type === 'done' 由末尾统一处理
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch('/console/api/apps/upgrade', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': auth.token },
|
|
body: JSON.stringify({ id: up.id, tag: (up.tag || '').trim() }),
|
|
})
|
|
const reader = resp.body!.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buf = ''
|
|
while (true) {
|
|
const r = await reader.read()
|
|
if (r.done) break
|
|
buf += decoder.decode(r.value, { stream: true })
|
|
const lines = buf.split('\n')
|
|
buf = lines.pop() as string
|
|
for (let i = 0; i < lines.length; i++) handleLine(lines[i] as string)
|
|
}
|
|
handleLine(buf)
|
|
} catch (ex: any) {
|
|
hadError = '网络异常: ' + ex.message
|
|
append('✗ ' + hadError)
|
|
}
|
|
|
|
if (needLogout) { auth.logout(); router.push('/'); return }
|
|
up.running = false
|
|
up.btnText = '重新升级'
|
|
up.ok = !hadError
|
|
if (hadError) { error(hadError) } else { success('升级完成'); loadAll() }
|
|
}
|
|
|
|
onMounted(loadAll)
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 应用卡片:应用级(卡片头) 与 部署级(卡片内表格) 视觉分层 */
|
|
.app-card {
|
|
background: #fff;
|
|
border: 1px solid #eef2f7;
|
|
border-radius: 14px;
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
|
overflow: hidden;
|
|
}
|
|
.app-card.orphan {
|
|
border-color: #fde68a;
|
|
}
|
|
.app-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 13px 16px;
|
|
cursor: pointer;
|
|
background: linear-gradient(90deg, #f5f7ff, #ffffff);
|
|
border-left: 4px solid #6366f1;
|
|
transition: background 0.15s;
|
|
}
|
|
.app-card.orphan .app-head {
|
|
background: linear-gradient(90deg, #fffbeb, #ffffff);
|
|
border-left-color: #f59e0b;
|
|
}
|
|
.app-head:hover {
|
|
background: linear-gradient(90deg, #eef2ff, #ffffff);
|
|
}
|
|
.chev {
|
|
color: #94a3b8;
|
|
font-size: 12px;
|
|
width: 16px;
|
|
flex-shrink: 0;
|
|
}
|
|
.app-logo {
|
|
width: 38px;
|
|
height: 38px;
|
|
border-radius: 10px;
|
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
color: #fff;
|
|
font-weight: 700;
|
|
font-size: 17px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.app-logo.orphan-logo {
|
|
background: #f59e0b;
|
|
}
|
|
.app-name {
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
color: #1e293b;
|
|
}
|
|
.chip-app {
|
|
display: inline-flex;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
padding: 1px 8px;
|
|
border-radius: 6px;
|
|
background: #eef2ff;
|
|
color: #4338ca;
|
|
}
|
|
.chip-dep {
|
|
display: inline-flex;
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
padding: 1px 6px;
|
|
border-radius: 5px;
|
|
background: #ecfeff;
|
|
color: #0e7490;
|
|
}
|
|
.chip-mono {
|
|
display: inline-flex;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
background: #f1f5f9;
|
|
color: #475569;
|
|
font-family: 'SF Mono', Menlo, monospace;
|
|
}
|
|
.chip-count {
|
|
display: inline-flex;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 1px 7px;
|
|
border-radius: 6px;
|
|
background: #f1f5f9;
|
|
color: #64748b;
|
|
}
|
|
.app-body {
|
|
padding: 4px 12px 10px 12px;
|
|
border-top: 1px solid #f1f5f9;
|
|
background: #fcfcfd;
|
|
}
|
|
.dep-row td {
|
|
vertical-align: middle;
|
|
}
|
|
</style>
|
|
|