展示证书逐台部署结果并修正密码认证提示
This commit is contained in:
parent
e817da8fc1
commit
85fcf91da0
@ -1,5 +1,6 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { settingsPayload } from "./SettingsPayload.js";
|
||||
import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js";
|
||||
|
||||
const defaultDNSVerificationTimeoutSeconds = 60;
|
||||
const aliyunDNSVerificationTimeoutSeconds = 6;
|
||||
@ -71,6 +72,8 @@ export function useDashboard() {
|
||||
const taskAttempts = ref([]);
|
||||
const taskDetailsLoading = ref(false);
|
||||
const taskDetailsError = ref("");
|
||||
const deploymentResult = ref(null);
|
||||
const domainActionRunning = ref(false);
|
||||
const hasOpenModal = computed(
|
||||
() =>
|
||||
showForm.value ||
|
||||
@ -78,6 +81,7 @@ export function useDashboard() {
|
||||
showSSHKeyForm.value ||
|
||||
showSettings.value ||
|
||||
showCertificateImport.value ||
|
||||
deploymentResult.value !== null ||
|
||||
showTaskDetails.value,
|
||||
);
|
||||
let previousBodyOverflow = "";
|
||||
@ -719,6 +723,11 @@ export function useDashboard() {
|
||||
}
|
||||
|
||||
async function runDomainAction(item, action, successMessage) {
|
||||
if (domainActionRunning.value) return;
|
||||
domainActionRunning.value = true;
|
||||
if (action === "deploy") {
|
||||
deploymentResult.value = { domain: item.name, running: true, report: null, error: "" };
|
||||
}
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
@ -729,12 +738,26 @@ export function useDashboard() {
|
||||
}
|
||||
const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options);
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (action === "deploy") {
|
||||
deploymentResult.value = {
|
||||
domain: item.name,
|
||||
running: false,
|
||||
report: result.deployment || null,
|
||||
error: response.ok && result.deployment ? "" : result.error || deploymentSummary(null),
|
||||
};
|
||||
}
|
||||
if (!response.ok) throw new Error(result.error || "域名操作失败");
|
||||
notice.value = successMessage;
|
||||
if (action === "deploy") notice.value = deploymentSummary(result.deployment);
|
||||
} catch (err) {
|
||||
error.value = err.message || "域名操作失败";
|
||||
} finally {
|
||||
const actionError = error.value;
|
||||
if (action === "deploy" && deploymentResult.value) {
|
||||
deploymentResult.value.running = false;
|
||||
if (actionError) deploymentResult.value.error = actionError;
|
||||
}
|
||||
domainActionRunning.value = false;
|
||||
await loadData();
|
||||
if (actionError) error.value = actionError;
|
||||
}
|
||||
@ -1262,6 +1285,7 @@ export function useDashboard() {
|
||||
dnsProviders,
|
||||
environmentLabels,
|
||||
webServerLabels,
|
||||
serverAuthLabel,
|
||||
isEditing,
|
||||
formTitle,
|
||||
form,
|
||||
@ -1319,6 +1343,7 @@ export function useDashboard() {
|
||||
taskAttempts,
|
||||
taskDetailsLoading,
|
||||
taskDetailsError,
|
||||
deploymentResult,
|
||||
taskTypeLabel,
|
||||
auditActionLabel,
|
||||
taskStageLabel,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import "./DeploymentResult.test.js";
|
||||
import test from "node:test";
|
||||
import { nextTick, ref } from "vue";
|
||||
import { useDashboard } from "./Dashboard.js";
|
||||
@ -108,6 +109,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
|
||||
app.component(name, { render: () => null });
|
||||
}
|
||||
app.component("UserManagement", { render: () => Vue.h("h2", headings.users) });
|
||||
app.component("DeploymentResult", { render: () => null });
|
||||
const warnings = [];
|
||||
app.config.warnHandler = (message) => warnings.push(message);
|
||||
const html = await renderToString(app);
|
||||
|
||||
@ -8,6 +8,7 @@ import SSHKeyModal from "./SSHKeyModal.vue";
|
||||
import DashboardNavigation from "./DashboardNavigation.vue";
|
||||
import { useDashboardNavigation } from "./DashboardNavigation.js";
|
||||
import { useDashboard } from "./Dashboard.js";
|
||||
import DeploymentResult from "./DeploymentResult.vue";
|
||||
|
||||
const dashboard = useDashboard();
|
||||
const {
|
||||
@ -128,6 +129,8 @@ const {
|
||||
taskAttempts,
|
||||
taskDetailsLoading,
|
||||
taskDetailsError,
|
||||
deploymentResult,
|
||||
serverAuthLabel,
|
||||
displayLabel,
|
||||
formatDate,
|
||||
daysRemaining,
|
||||
@ -471,7 +474,8 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
||||
</div>
|
||||
<div class="domain-meta">
|
||||
<span>{{ item.username }}@{{ item.address }}:{{ item.port }}</span
|
||||
><span>私钥引用已配置</span
|
||||
><span v-if="item.auth_method === 'private_key' && item.private_key_ref">私钥引用已配置</span
|
||||
><span v-else>{{ serverAuthLabel(item) }}</span
|
||||
><span>指纹:{{ item.host_key_fingerprint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -550,6 +554,12 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
||||
|
||||
<SSHKeyModal v-if="showSSHKeyForm" :state="dashboard" />
|
||||
|
||||
<DeploymentResult
|
||||
v-if="deploymentResult"
|
||||
:result="deploymentResult"
|
||||
@close="deploymentResult = null"
|
||||
/>
|
||||
|
||||
<div v-if="showTaskDetails" class="modal-backdrop" @click.self="closeTaskDetails">
|
||||
<section class="modal-card task-details-modal" role="dialog" aria-modal="true" aria-label="任务详情">
|
||||
<div class="modal-heading">
|
||||
|
||||
25
frontend/src/views/Dashboard/DeploymentFeedback.js
Normal file
25
frontend/src/views/Dashboard/DeploymentFeedback.js
Normal file
@ -0,0 +1,25 @@
|
||||
export function serverAuthLabel(server) {
|
||||
if (server.auth_method === "password") {
|
||||
return server.password_configured ? "SSH 密码已配置" : "SSH 密码未配置";
|
||||
}
|
||||
if (server.auth_method === "private_key") {
|
||||
return server.private_key_ref ? "SSH 私钥已配置" : "SSH 私钥未配置";
|
||||
}
|
||||
return "认证方式未确认";
|
||||
}
|
||||
|
||||
export function deploymentSummary(report) {
|
||||
if (!report || !Array.isArray(report.results)) return "未收到有效部署结果";
|
||||
if (report.total === 0 && report.results.length === 0) return "未配置部署目标,未执行推送";
|
||||
if (report.status === "success" && report.total > 0 &&
|
||||
report.success === report.total && report.failed === 0 &&
|
||||
report.results.length === report.total &&
|
||||
report.results.every((item) => item.status === "success")) {
|
||||
return `证书部署完成:${report.success} 台成功`;
|
||||
}
|
||||
return "证书部署未全部成功,请检查各服务器结果";
|
||||
}
|
||||
|
||||
export function deploymentStatusLabel(status) {
|
||||
return { success: "成功", failed: "失败", partial: "部分失败" }[status] || "未确认";
|
||||
}
|
||||
28
frontend/src/views/Dashboard/DeploymentResult.css
Normal file
28
frontend/src/views/Dashboard/DeploymentResult.css
Normal file
@ -0,0 +1,28 @@
|
||||
.deployment-result {
|
||||
width: min(680px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.deployment-summary {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.deployment-targets {
|
||||
max-height: 40dvh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.deployment-target {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.deployment-target p {
|
||||
margin: 6px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
86
frontend/src/views/Dashboard/DeploymentResult.test.js
Normal file
86
frontend/src/views/Dashboard/DeploymentResult.test.js
Normal file
@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { parse, compileTemplate } from "@vue/compiler-sfc";
|
||||
import { useDashboard } from "./Dashboard.js";
|
||||
import { serverAuthLabel, deploymentSummary } from "./DeploymentFeedback.js";
|
||||
import { mountComposable, deferred } from "../../test-support/mount.js";
|
||||
|
||||
const report = {
|
||||
total: 1,
|
||||
success: 1,
|
||||
failed: 0,
|
||||
status: "success",
|
||||
results: [{ server_id: "server-1", server: "验收服务器", status: "success", message: "部署完成" }],
|
||||
};
|
||||
|
||||
test("服务器认证提示区分密码、私钥及缺失配置", () => {
|
||||
assert.equal(serverAuthLabel({ auth_method: "password", password_configured: true }), "SSH 密码已配置");
|
||||
assert.equal(serverAuthLabel({ auth_method: "password", private_key_ref: "secret://test" }), "SSH 密码未配置");
|
||||
assert.equal(serverAuthLabel({ auth_method: "private_key", private_key_ref: "secret://test" }), "SSH 私钥已配置");
|
||||
assert.equal(serverAuthLabel({ auth_method: "private_key" }), "SSH 私钥未配置");
|
||||
assert.equal(serverAuthLabel({}), "认证方式未确认");
|
||||
});
|
||||
|
||||
test("部署汇总不把空目标、缺失结果或部分失败称为成功", () => {
|
||||
assert.equal(deploymentSummary(report), "证书部署完成:1 台成功");
|
||||
assert.equal(deploymentSummary(null), "未收到有效部署结果");
|
||||
assert.equal(deploymentSummary({ total: 0, results: [] }), "未配置部署目标,未执行推送");
|
||||
assert.match(deploymentSummary({ ...report, results: [] }), /未全部成功/);
|
||||
assert.match(deploymentSummary({ ...report, status: "partial", failed: 1 }), /未全部成功/);
|
||||
});
|
||||
|
||||
test("手动部署展示真实返回结果并阻止执行期间重复提交", async (t) => {
|
||||
const pending = deferred();
|
||||
let requests = 0;
|
||||
const { state } = await mountComposable(t, useDashboard, async (path) => {
|
||||
if (path.endsWith("/deploy")) {
|
||||
requests++;
|
||||
return pending.promise;
|
||||
}
|
||||
return Response.json({ items: [] });
|
||||
});
|
||||
const action = state.deployDomain({ id: "domain-1", name: "example.test" });
|
||||
assert.equal(state.deploymentResult.value.running, true);
|
||||
await state.deployDomain({ id: "domain-1", name: "example.test" });
|
||||
assert.equal(requests, 1);
|
||||
pending.resolve(Response.json({ deployment: report }));
|
||||
await action;
|
||||
assert.equal(state.deploymentResult.value.running, false);
|
||||
assert.deepEqual(state.deploymentResult.value.report, report);
|
||||
assert.equal(state.notice.value, "证书部署完成:1 台成功");
|
||||
});
|
||||
|
||||
test("部署失败仍保留各服务器结果,网络失败不会残留执行中状态", async (t) => {
|
||||
let failNetwork = false;
|
||||
const failed = { ...report, status: "failed", success: 0, failed: 1, results: [{ ...report.results[0], status: "failed" }] };
|
||||
const { state } = await mountComposable(t, useDashboard, async (path) => {
|
||||
if (path.endsWith("/deploy")) {
|
||||
if (failNetwork) throw new Error("network-test-failure");
|
||||
return Response.json({ error: "部署失败", deployment: failed }, { status: 502 });
|
||||
}
|
||||
return Response.json({ items: [] });
|
||||
});
|
||||
await state.deployDomain({ id: "domain-1", name: "example.test" });
|
||||
assert.deepEqual(state.deploymentResult.value.report, failed);
|
||||
assert.equal(state.deploymentResult.value.error, "部署失败");
|
||||
assert.equal(state.notice.value, "");
|
||||
failNetwork = true;
|
||||
await state.deployDomain({ id: "domain-1", name: "example.test" });
|
||||
assert.equal(state.deploymentResult.value.running, false);
|
||||
assert.equal(state.deploymentResult.value.report, null);
|
||||
assert.equal(state.deploymentResult.value.error, "network-test-failure");
|
||||
});
|
||||
|
||||
test("部署结果组件可编译且认证提示绑定真实配置", async () => {
|
||||
const source = await readFile(new URL("./DeploymentResult.vue", import.meta.url), "utf8");
|
||||
const { descriptor, errors } = parse(source);
|
||||
assert.deepEqual(errors, []);
|
||||
const compiled = compileTemplate({ source: descriptor.template.content, filename: "DeploymentResult.vue", id: "deployment-result" });
|
||||
assert.deepEqual(compiled.errors, []);
|
||||
assert.match(source, /:disabled="result.running"/);
|
||||
assert.match(source, /role="dialog"/);
|
||||
const dashboard = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
|
||||
assert.match(dashboard, /serverAuthLabel\(item\)/);
|
||||
assert.match(dashboard, /item.auth_method === 'private_key' && item.private_key_ref/);
|
||||
});
|
||||
63
frontend/src/views/Dashboard/DeploymentResult.vue
Normal file
63
frontend/src/views/Dashboard/DeploymentResult.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { deploymentStatusLabel, deploymentSummary } from "./DeploymentFeedback.js";
|
||||
|
||||
defineProps({ result: { type: Object, required: true } });
|
||||
defineEmits(["close"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-backdrop" @click.self="!result.running && $emit('close')">
|
||||
<section
|
||||
class="modal-card deployment-result"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="deployment-result-title"
|
||||
:aria-busy="result.running"
|
||||
>
|
||||
<div class="modal-heading">
|
||||
<div>
|
||||
<h2 id="deployment-result-title">证书部署结果</h2>
|
||||
<p class="muted">{{ result.domain }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="close-button"
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
:disabled="result.running"
|
||||
@click="$emit('close')"
|
||||
>×</button>
|
||||
</div>
|
||||
<p v-if="result.running" role="status">正在连接服务器、推送证书并校验重载,请稍候……</p>
|
||||
<template v-else>
|
||||
<p v-if="result.error" class="form-error" role="alert">{{ result.error }}</p>
|
||||
<p v-if="result.report" class="deployment-summary" role="status">
|
||||
{{ deploymentSummary(result.report) }}
|
||||
</p>
|
||||
<div v-if="result.report" class="deployment-targets" tabindex="0" aria-label="各服务器部署结果">
|
||||
<article
|
||||
v-for="(item, index) in result.report.results"
|
||||
:key="item.server_id || index"
|
||||
class="deployment-target"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.server || "未命名服务器" }}</strong>
|
||||
<p>{{ item.message || "无附加信息" }}</p>
|
||||
</div>
|
||||
<span class="tag" :class="item.status === 'success' ? 'status-success' : 'status-error'">
|
||||
{{ deploymentStatusLabel(item.status) }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
<p class="muted">此处显示本次推送结果;远程站点是否使用新证书,仍需访问站点验证。</p>
|
||||
</template>
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-button" type="button" :disabled="result.running" @click="$emit('close')">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="./Dashboard.css"></style>
|
||||
<style scoped src="./DeploymentResult.css"></style>
|
||||
Loading…
Reference in New Issue
Block a user