DeployDialog.vue 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. <template>
  2. <el-dialog title="一键部署" :visible.sync="visible" width="680px" append-to-body @close="handleClose">
  3. <!-- Step 1: Upload -->
  4. <div v-if="step === 1">
  5. <el-upload
  6. ref="uploadRef"
  7. drag
  8. :auto-upload="false"
  9. :limit="1"
  10. :on-change="handleFileChange"
  11. :on-remove="handleFileRemove"
  12. action="">
  13. <i class="el-icon-upload"></i>
  14. <div class="el-upload__text">将 ipole 文件拖到此处,或<em>点击上传</em></div>
  15. </el-upload>
  16. <el-divider></el-divider>
  17. <el-descriptions :column="2" border size="small">
  18. <el-descriptions-item label="目标网关">{{ gid }}</el-descriptions-item>
  19. <el-descriptions-item label="当前版本">{{ currentVersion || '未知' }}</el-descriptions-item>
  20. <el-descriptions-item label="文件名">{{ fileInfo.name || '--' }}</el-descriptions-item>
  21. <el-descriptions-item label="文件大小">{{ fileInfo.size || '--' }}</el-descriptions-item>
  22. </el-descriptions>
  23. <div style="margin-top: 16px; text-align: right">
  24. <el-button @click="handleClose">取消</el-button>
  25. <el-button type="primary" :disabled="!fileInfo.name" @click="step = 2">下一步</el-button>
  26. </div>
  27. </div>
  28. <!-- Step 2: Confirm -->
  29. <div v-if="step === 2">
  30. <el-alert title="确认部署信息" type="warning" :closable="false" show-icon style="margin-bottom: 16px">
  31. <div>即将把 <strong>{{ gid }}</strong> 升级到新版本</div>
  32. <div style="margin-top: 4px; font-size: 12px; color: #909399">
  33. 部署期间网关将短暂离线,goforever 会自动重启新版本
  34. </div>
  35. </el-alert>
  36. <el-descriptions :column="1" border size="small">
  37. <el-descriptions-item label="文件">{{ fileInfo.name }} ({{ fileInfo.size }})</el-descriptions-item>
  38. <el-descriptions-item label="MD5">{{ fileInfo.md5 || '--' }}</el-descriptions-item>
  39. </el-descriptions>
  40. <div style="margin-top: 16px; text-align: right">
  41. <el-button @click="step = 1">上一步</el-button>
  42. <el-button type="primary" :loading="pushing" @click="handlePush">确认下发</el-button>
  43. </div>
  44. </div>
  45. <!-- Step 3: Result -->
  46. <div v-if="step === 3">
  47. <div style="text-align: center; padding: 20px 0">
  48. <el-steps :active="pipelineStep" finish-status="success" align-center>
  49. <el-step title="上传"></el-step>
  50. <el-step title="下发"></el-step>
  51. <el-step title="P1检测"></el-step>
  52. <el-step title="P2检测"></el-step>
  53. </el-steps>
  54. </div>
  55. <div v-if="deployResult" style="margin-top: 16px">
  56. <el-alert
  57. :title="deployResult.success ? '部署成功' : '部署失败'"
  58. :type="deployResult.success ? 'success' : 'error'"
  59. :closable="false" show-icon>
  60. </el-alert>
  61. <!-- Phase 1 检测项 -->
  62. <div style="margin-top: 12px" v-if="deployResult.phase1Checks && deployResult.phase1Checks.length">
  63. <p style="font-size:13px;color:#909399;margin-bottom:4px">Phase 1 — 基础设施检测</p>
  64. <div v-for="check in deployResult.phase1Checks" :key="check.Name"
  65. style="display:flex;align-items:center;padding:6px 0">
  66. <i :class="check.Status === 'ok' ? 'el-icon-success' : 'el-icon-error'"
  67. :style="{color: check.Status === 'ok' ? '#67c23a' : '#f56c6c'}"></i>
  68. <span style="margin-left: 8px">{{ checkLabels[check.Name] || check.Name }}</span>
  69. <span style="margin-left: auto; font-size: 12px; color: #909399">{{ check.Detail }}</span>
  70. </div>
  71. </div>
  72. <!-- Phase 2 检测项 -->
  73. <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #eee"
  74. v-if="deployResult.phase2Checks && deployResult.phase2Checks.length">
  75. <p style="font-size:13px;color:#909399;margin-bottom:4px">Phase 2 — 业务恢复确认</p>
  76. <div v-for="check in deployResult.phase2Checks" :key="check.Name"
  77. style="display:flex;align-items:center;padding:6px 0">
  78. <i :class="check.Status === 'ok' ? 'el-icon-success' : 'el-icon-error'"
  79. :style="{color: check.Status === 'ok' ? '#67c23a' : '#f56c6c'}"></i>
  80. <span style="margin-left: 8px">{{ checkLabels[check.Name] || check.Name }}</span>
  81. <span style="margin-left: auto; font-size: 12px; color: #909399">{{ check.Detail }}</span>
  82. </div>
  83. </div>
  84. <div v-else-if="deployResult.success"
  85. style="margin-top:12px;padding:8px;background:#fff3e0;border-radius:4px;font-size:12px;color:#e65100">
  86. ⏳ Phase 2 业务恢复检测将在约5分钟后完成,请稍后刷新...
  87. </div>
  88. </div>
  89. <div v-if="!deployResult" style="text-align: center; padding: 20px; color: #909399">
  90. <i class="el-icon-loading" style="font-size: 24px"></i>
  91. <p>等待网关响应...</p>
  92. <p style="font-size: 12px">网关正在重启并运行健康检测</p>
  93. </div>
  94. <div style="margin-top: 16px; text-align: right">
  95. <el-button @click="handleClose">关闭</el-button>
  96. </div>
  97. </div>
  98. </el-dialog>
  99. </template>
  100. <script>
  101. import { deployUpload, deployPush, deployStatus } from '@/api/longchi/device/gatewayconfig.js'
  102. export default {
  103. name: 'DeployDialog',
  104. props: {
  105. gid: { type: String, required: true },
  106. tenant: { type: String, required: true },
  107. currentVersion: { type: String, default: '' }
  108. },
  109. data() {
  110. return {
  111. visible: false,
  112. step: 1,
  113. fileInfo: {},
  114. pushing: false,
  115. pipelineStep: 0,
  116. deployResult: null,
  117. pollTimer: null,
  118. p2Timer: null,
  119. checkLabels: {
  120. mqtt: 'MQTT连接',
  121. redis: 'Redis',
  122. serial: '串口',
  123. modbus: '设备采集',
  124. device_online: '设备在线'
  125. }
  126. }
  127. },
  128. methods: {
  129. open() {
  130. this.visible = true
  131. this.step = 1
  132. this.fileInfo = {}
  133. this.deployResult = null
  134. },
  135. handleFileChange(file) {
  136. this.fileInfo = {
  137. name: file.name,
  138. size: this.formatSize(file.size),
  139. file: file.raw
  140. }
  141. },
  142. handleFileRemove() {
  143. this.fileInfo = {}
  144. },
  145. formatSize(bytes) {
  146. if (bytes < 1024) return bytes + ' B'
  147. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
  148. return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
  149. },
  150. async handlePush() {
  151. this.pushing = true
  152. try {
  153. // 1. Upload
  154. this.pipelineStep = 0
  155. const uploadRes = await deployUpload(this.fileInfo.file, this.gid, this.tenant)
  156. if (uploadRes.data.code !== 0) {
  157. this.$message.error('上传失败: ' + (uploadRes.data.message || '未知错误'))
  158. this.pushing = false
  159. return
  160. }
  161. this.pipelineStep = 1
  162. this.fileInfo.md5 = uploadRes.data.data.md5
  163. // 2. Push
  164. const pushRes = await deployPush(
  165. this.gid, this.tenant, '', 'ipole',
  166. uploadRes.data.data.path, uploadRes.data.data.md5
  167. )
  168. if (pushRes.data.code !== 0) {
  169. this.$message.error('下发失败: ' + (pushRes.data.message || '未知错误'))
  170. this.pushing = false
  171. return
  172. }
  173. this.pipelineStep = 2
  174. // 3. Poll results
  175. this.step = 3
  176. this.pollResult()
  177. } catch (e) {
  178. this.$message.error('部署失败: ' + (e.message || e))
  179. }
  180. this.pushing = false
  181. },
  182. pollResult() {
  183. let attempts = 0
  184. this.pollTimer = setInterval(async () => {
  185. attempts++
  186. try {
  187. const res = await deployStatus(this.gid)
  188. if (res.data.code === 0 && res.data.data) {
  189. const d = res.data.data
  190. // status !== 0 说明部署已结束,但需等 phase1Result 有值(health 数据晚于 ack 到达)
  191. if (d.status !== 0 && d.phase1Result) {
  192. clearInterval(this.pollTimer)
  193. const p1 = JSON.parse(d.phase1Result)
  194. const p2 = d.phase2Result ? JSON.parse(d.phase2Result) : null
  195. this.deployResult = {
  196. success: d.status === 1,
  197. phase1Checks: p1 ? p1.Checks : [],
  198. phase2Checks: p2 ? p2.Checks : []
  199. }
  200. this.pipelineStep = p2 ? 5 : 4
  201. // P1 有数据但 P2 为空,继续慢速轮询等 P2
  202. if (!p2) {
  203. this.pollPhase2()
  204. }
  205. }
  206. // 部署失败(status=2/3),无需等 health,直接展示
  207. if (d.status === 2 || d.status === 3) {
  208. clearInterval(this.pollTimer)
  209. const p1 = d.phase1Result ? JSON.parse(d.phase1Result) : null
  210. this.deployResult = {
  211. success: false,
  212. phase1Checks: p1 ? p1.Checks : [],
  213. phase2Checks: [],
  214. error: d.errorMsg
  215. }
  216. }
  217. }
  218. } catch (e) {
  219. // continue polling
  220. }
  221. if (attempts > 120) {
  222. clearInterval(this.pollTimer)
  223. this.$message.warning('等待超时,请手动检查网关状态')
  224. }
  225. }, 3000)
  226. },
  227. pollPhase2() {
  228. let attempts = 0
  229. this.p2Timer = setInterval(async () => {
  230. attempts++
  231. try {
  232. const res = await deployStatus(this.gid)
  233. if (res.data.code === 0 && res.data.data && res.data.data.phase2Result) {
  234. clearInterval(this.p2Timer)
  235. const p2 = JSON.parse(res.data.data.phase2Result)
  236. this.deployResult.phase2Checks = p2.Checks || []
  237. this.pipelineStep = 5
  238. }
  239. } catch (e) {
  240. // continue
  241. }
  242. if (attempts > 40) { // 10s × 40 = ~7min
  243. clearInterval(this.p2Timer)
  244. }
  245. }, 10000)
  246. },
  247. handleClose() {
  248. if (this.pollTimer) {
  249. clearInterval(this.pollTimer)
  250. }
  251. if (this.p2Timer) {
  252. clearInterval(this.p2Timer)
  253. }
  254. this.visible = false
  255. this.$emit('close')
  256. this.$emit('refresh')
  257. }
  258. }
  259. }
  260. </script>