优化运行概览布局并建立用户分组改造前检查点
This commit is contained in:
parent
35d2c91999
commit
c91aab659b
@ -3,6 +3,7 @@ import test from "node:test";
|
||||
import { nextTick, ref } from "vue";
|
||||
import { useDashboard } from "./Dashboard.js";
|
||||
import { useDashboardNavigation } from "./DashboardNavigation.js";
|
||||
import { summarizeRuntime, runtimeTime, useRuntimeOverview } from "./RuntimeOverview.js";
|
||||
import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../../test-support/mount.js";
|
||||
|
||||
test("板块导航默认只选中基本信息,超级管理员可切换全部板块", () => {
|
||||
@ -88,7 +89,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
|
||||
const navigation = useDashboardNavigation(state.currentUser);
|
||||
const headings = {
|
||||
overview: "基本信息",
|
||||
activity: "最近活动",
|
||||
activity: "运行概览",
|
||||
domains: "托管域名",
|
||||
servers: "部署服务器",
|
||||
keys: "SSH 私钥",
|
||||
@ -100,7 +101,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
|
||||
render,
|
||||
setup: () => ({ ...state, ...navigation, dashboard: state }),
|
||||
});
|
||||
for (const name of ["ChangePassword", "DashboardNavigation", "SettingsModal", "DomainModal", "ServerModal", "SSHKeyModal"]) {
|
||||
for (const name of ["ChangePassword", "DashboardNavigation", "SettingsModal", "DomainModal", "ServerModal", "SSHKeyModal", "RuntimeOverview"]) {
|
||||
app.component(name, { render: () => null });
|
||||
}
|
||||
app.component("UserManagement", { render: () => Vue.h("h2", headings.users) });
|
||||
@ -389,7 +390,7 @@ test("auditStatusRendering", async () => {
|
||||
const { compileTemplate } = await import("@vue/compiler-sfc");
|
||||
const { renderToString } = await import("@vue/server-renderer");
|
||||
const { createSSRApp } = await import("vue");
|
||||
const source = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
|
||||
const source = await readFile(new URL("./RuntimeOverview.vue", import.meta.url), "utf8");
|
||||
const start = source.indexOf('<article v-for="item in auditLogs"');
|
||||
assert.notEqual(start, -1);
|
||||
const end = source.indexOf("</article>", start) + "</article>".length;
|
||||
@ -681,11 +682,104 @@ test("domainAction.refreshesSuccessfulResultAndRetainsNotice", async (t) => {
|
||||
|
||||
test("dashboardLayout.keepsActivityListsBoundedAndKeyboardAccessible", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
|
||||
const stylesheet = await readFile(new URL("./Dashboard.css", import.meta.url), "utf8");
|
||||
assert.equal((template.match(/class="record-list" tabindex="0"/g) || []).length, 3);
|
||||
assert.match(stylesheet, /\.record-list\s*\{[^}]*max-height:\s*280px;[^}]*overflow-y:\s*auto;/);
|
||||
assert.match(stylesheet, /\.record-list:focus-visible/);
|
||||
const template = await readFile(new URL("./RuntimeOverview.vue", import.meta.url), "utf8");
|
||||
const stylesheet = await readFile(new URL("./RuntimeOverview.css", import.meta.url), "utf8");
|
||||
assert.match(template, /class="runtime-table-scroll" tabindex="0"/);
|
||||
assert.match(template, /class="runtime-audit-list" tabindex="0"/);
|
||||
assert.match(stylesheet, /\.runtime-audit-list\s*\{[^}]*max-height:[^;]+;[^}]*overflow:\s*auto;/);
|
||||
assert.match(stylesheet, /\.runtime-table-scroll:focus-visible/);
|
||||
assert.match(stylesheet, /\.runtime-audit-list:focus-visible/);
|
||||
});
|
||||
|
||||
test("runtimeOverview.summarizesCertificateBoundariesAndConfiguration", () => {
|
||||
const now = Date.parse("2026-09-25T12:00:00Z");
|
||||
const day = 86400000;
|
||||
const domains = ["valid", "near", "expired", "missing", "invalid", "future"].map((id) => ({ id, name: `${id}.test`, auto_renew: id === "valid" }));
|
||||
const certificate = (id, end, start = now - day) => ({ domain: { id }, certificate: { saved: { not_after: end, not_before: new Date(start).toISOString() } } });
|
||||
const result = summarizeRuntime({
|
||||
domains,
|
||||
certificates: [
|
||||
certificate("valid", new Date(now + 11 * day).toISOString()),
|
||||
certificate("near", new Date(now + 10 * day).toISOString()),
|
||||
certificate("expired", new Date(now).toISOString()),
|
||||
certificate("invalid", "invalid"),
|
||||
certificate("future", new Date(now + 30 * day).toISOString(), now + day),
|
||||
],
|
||||
settings: { acme: { renewal_near_expiry_days: 10 } },
|
||||
servers: [{ id: "server" }],
|
||||
domainServers: { valid: [{ id: "server" }], near: [] },
|
||||
}, now);
|
||||
assert.equal(result.total, 6);
|
||||
assert.equal(result.valid, 2);
|
||||
assert.equal(result.expiring, 1);
|
||||
assert.equal(result.expired, 1);
|
||||
assert.equal(result.missing, 3);
|
||||
assert.equal(result.alerts.length, 5);
|
||||
assert.equal(result.alerts[0].id, "expired");
|
||||
assert.equal(result.autoRenew, 1);
|
||||
assert.equal(result.configuredServers, 1);
|
||||
assert.equal(result.boundDomains, 1);
|
||||
});
|
||||
|
||||
test("runtimeOverview.handlesEmptyDataThresholdsAndTaskOrdering", () => {
|
||||
const empty = summarizeRuntime({});
|
||||
for (const key of ["total", "valid", "expiring", "expired", "missing", "autoRenew", "configuredServers", "boundDomains", "activeTasks", "failedTasks"]) assert.equal(empty[key], 0);
|
||||
for (const value of [0, -1, "invalid"]) assert.equal(summarizeRuntime({ settings: { acme: { renewal_near_expiry_days: value } } }).threshold, 10);
|
||||
assert.equal(summarizeRuntime({ settings: { acme: { renewal_near_expiry_days: 30 } } }).threshold, 30);
|
||||
const tasks = ["queued", "running", "failed", "error", "success", "cancelled"].map((status, index) => ({ id: index, status, created_at: `2026-09-25T12:0${index}:00Z` }));
|
||||
const original = structuredClone(tasks);
|
||||
const result = summarizeRuntime({ tasks });
|
||||
assert.equal(result.activeTasks, 2);
|
||||
assert.equal(result.failedTasks, 2);
|
||||
assert.deepEqual(result.tasks.map((item) => item.id), [5, 4, 3, 2, 1, 0]);
|
||||
assert.deepEqual(tasks, original);
|
||||
assert.equal(runtimeTime(null), runtimeTime("invalid"));
|
||||
assert.doesNotMatch(runtimeTime("2026-09-25T12:00:00Z"), /Invalid/);
|
||||
});
|
||||
|
||||
test("runtimeOverview.defaultsToSummaryAndLimitsRecentTasks", async (t) => {
|
||||
const data = {
|
||||
domains: ref([{ id: "domain", name: "example.test" }]), certificates: ref([]),
|
||||
servers: ref([]), domainServers: ref({}), settings: ref({}),
|
||||
tasks: ref(Array.from({ length: 12 }, (_, id) => ({ id, status: "success" }))),
|
||||
};
|
||||
const { state } = await mountComposable(t, () => useRuntimeOverview(data), async () => Response.json({}));
|
||||
assert.equal(state.detailView.value, "");
|
||||
assert.equal(state.visibleTasks.value.length, 5);
|
||||
state.detailView.value = "tasks";
|
||||
assert.equal(state.visibleTasks.value.length, 12);
|
||||
state.detailView.value = "";
|
||||
assert.equal(state.visibleTasks.value.length, 5);
|
||||
assert.equal(state.taskDomain({ domain_id: "domain" }), "example.test");
|
||||
assert.notEqual(state.taskDomain({ task_type: "management_renewal" }), state.taskDomain({}));
|
||||
});
|
||||
|
||||
test("runtimeOverview.compilesAndKeepsAuditOutOfDefaultView", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { parse, compileScript, compileTemplate } = await import("@vue/compiler-sfc");
|
||||
const { renderToString } = await import("@vue/server-renderer");
|
||||
const Vue = await import("vue");
|
||||
const source = await readFile(new URL("./RuntimeOverview.vue", import.meta.url), "utf8");
|
||||
const { descriptor, errors } = parse(source);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(compileScript(descriptor, { id: "runtime-overview" }));
|
||||
const compiled = compileTemplate({ source: descriptor.template.content, filename: "RuntimeOverview.vue", id: "runtime-overview", compilerOptions: { mode: "function" } });
|
||||
assert.deepEqual(compiled.errors, []);
|
||||
const render = new Function("Vue", compiled.code)(Vue);
|
||||
for (const detailView of ["", "tasks", "audit"]) {
|
||||
const app = Vue.createSSRApp({ render, setup: () => ({
|
||||
loading: false, error: "", detailView, summary: summarizeRuntime({}),
|
||||
visibleTasks: [], auditLogs: [{ id: "audit", action: "login", status: "success", message: "audit-only-marker" }],
|
||||
auditActionLabel: (value) => value, statusClass: () => "success",
|
||||
}) });
|
||||
app.component("HelpTip", { render: () => null });
|
||||
const warnings = [];
|
||||
app.config.warnHandler = (message) => warnings.push(message);
|
||||
const html = await renderToString(app);
|
||||
assert.equal(html.includes('class="runtime-metrics"'), detailView === "");
|
||||
assert.equal(html.includes("audit-only-marker"), detailView === "audit");
|
||||
assert.deepEqual(warnings, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("dashboardLayout.replacesHeroWithBasicInformationSummary", async () => {
|
||||
|
||||
@ -4,6 +4,7 @@ import UserManagement from "../UserManagement/UserManagement.vue";
|
||||
import SettingsModal from "./SettingsModal.vue";
|
||||
import DomainModal from "./DomainModal.vue";
|
||||
import ServerModal from "./ServerModal.vue";
|
||||
import RuntimeOverview from "./RuntimeOverview.vue";
|
||||
import SSHKeyModal from "./SSHKeyModal.vue";
|
||||
import DashboardNavigation from "./DashboardNavigation.vue";
|
||||
import { useDashboardNavigation } from "./DashboardNavigation.js";
|
||||
@ -327,70 +328,11 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
||||
<section v-if="activeSection === 'activity'" class="panel operations-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">运行概览</p>
|
||||
<h2>最近活动</h2>
|
||||
</div>
|
||||
<span class="muted">最近 20 条记录</span>
|
||||
</div>
|
||||
<div class="operation-columns">
|
||||
<div>
|
||||
<h3>证书状态</h3>
|
||||
<div v-if="certificates.length === 0" class="empty-state compact-empty">暂无证书记录</div>
|
||||
<div v-else class="record-list" tabindex="0">
|
||||
<article v-for="item in certificates" :key="item.domain.id" class="record-row">
|
||||
<div>
|
||||
<strong>{{ item.domain.name }}</strong>
|
||||
<span v-if="item.certificate" class="muted">到期:{{ formatDate(item.certificate.saved.not_after) }}</span>
|
||||
<span v-else class="muted">{{ item.certificate_error || "读取失败" }}</span>
|
||||
</div>
|
||||
<span v-if="item.certificate" class="tag success">已保存</span>
|
||||
<span v-else class="tag warning">未保存</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>任务记录</h3>
|
||||
<div v-if="tasks.length === 0" class="empty-state compact-empty">暂无任务记录</div>
|
||||
<div v-else class="record-list" tabindex="0">
|
||||
<article v-for="item in tasks" :key="item.id" class="record-row">
|
||||
<div>
|
||||
<strong>{{ taskTypeLabel(item.task_type) }}</strong>
|
||||
<span class="muted">{{ item.message || "无附加信息" }}</span>
|
||||
</div>
|
||||
<div class="record-row-actions">
|
||||
<span class="tag" :class="statusClass(item.status)">{{ displayLabel(taskStatusLabels, item.status) }}</span>
|
||||
<button class="text-button" type="button" @click="openTaskDetails(item)">
|
||||
查看详情
|
||||
</button>
|
||||
<button
|
||||
v-if="item.task_type !== 'management_renewal' && (item.status === 'queued' || item.status === 'running')"
|
||||
class="text-button"
|
||||
type="button"
|
||||
@click="cancelTask(item)"
|
||||
>
|
||||
取消任务
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>审计日志</h3>
|
||||
<div v-if="auditLogs.length === 0" class="empty-state compact-empty">暂无审计日志</div>
|
||||
<div v-else class="record-list" tabindex="0">
|
||||
<article v-for="item in auditLogs" :key="item.id" class="record-row">
|
||||
<div>
|
||||
<strong>{{ auditActionLabel(item.action) }}</strong>
|
||||
<span class="muted">{{ item.message || item.resource_type }}</span>
|
||||
</div>
|
||||
<span v-if="item.status === 'success' || item.status === 'failed'" class="tag" :class="statusClass(item.status)">{{ item.status === "success" ? "成功" : "失败" }}</span>
|
||||
<span v-else class="tag" :class="statusClass(item.status)">
|
||||
{{ displayLabel(taskStatusLabels, item.status) }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
<h2>运行概览</h2>
|
||||
</div>
|
||||
<span class="muted">证书有效期 · 自动续期 · 任务执行</span>
|
||||
</div>
|
||||
<RuntimeOverview :state="dashboard" @navigate="selectSection" />
|
||||
</section>
|
||||
|
||||
<section v-if="activeSection === 'domains'" class="panel">
|
||||
|
||||
@ -2,7 +2,7 @@ import { computed, ref } from "vue";
|
||||
|
||||
export const dashboardSections = [
|
||||
{ id: "overview", label: "基本信息", group: "总览", hint: "查看用户、域名状态、证书到期时间和部署服务器" },
|
||||
{ id: "activity", label: "运行概览", group: "总览", hint: "查看证书、任务与审计日志" },
|
||||
{ id: "activity", label: "运行概览", group: "总览", hint: "查看证书有效期、续期覆盖和任务执行情况" },
|
||||
{ id: "domains", label: "域名配置", group: "证书与部署", hint: "管理域名、申请证书和执行部署" },
|
||||
{ id: "servers", label: "部署服务器", group: "证书与部署", hint: "配置部署目标及 SSH 认证方式" },
|
||||
{ id: "keys", label: "SSH 私钥", group: "证书与部署", hint: "管理加密保存的 SSH 私钥" },
|
||||
|
||||
250
frontend/src/views/Dashboard/RuntimeOverview.css
Normal file
250
frontend/src/views/Dashboard/RuntimeOverview.css
Normal file
@ -0,0 +1,250 @@
|
||||
.runtime-overview {
|
||||
color: #25344d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.runtime-toolbar,
|
||||
.runtime-actions,
|
||||
.runtime-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.runtime-toolbar {
|
||||
margin-bottom: 10px;
|
||||
color: #687792;
|
||||
}
|
||||
|
||||
.runtime-overview button {
|
||||
padding: 5px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #315faa;
|
||||
background: #eff4fb;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-overview button:focus-visible,
|
||||
.runtime-table-scroll:focus-visible,
|
||||
.runtime-audit-list:focus-visible {
|
||||
outline: 2px solid #315faa;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.runtime-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.runtime-metric {
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #e1e9f4;
|
||||
border-radius: 12px;
|
||||
background: #f7faff;
|
||||
}
|
||||
|
||||
.runtime-metric > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
color: #62748f;
|
||||
}
|
||||
|
||||
.runtime-metric > strong {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.runtime-metric small {
|
||||
color: #7b8ba3;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.runtime-metric p {
|
||||
margin: 6px 0 0;
|
||||
color: #687792;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-metric.attention {
|
||||
border-color: #f2dcbb;
|
||||
background: #fffbf4;
|
||||
}
|
||||
|
||||
.runtime-focus-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.runtime-focus {
|
||||
min-width: 0;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e6ecf5;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.runtime-section-heading {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.runtime-section-heading h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.runtime-section-heading > span,
|
||||
.runtime-empty {
|
||||
color: #7b8ba3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-clear {
|
||||
color: #248158;
|
||||
}
|
||||
|
||||
.runtime-alerts {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.runtime-alerts li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 4px 12px;
|
||||
padding: 5px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.runtime-deployment {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
|
||||
.runtime-deployment dt {
|
||||
color: #687792;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-deployment dd {
|
||||
margin: 4px 0 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-table-scroll,
|
||||
.runtime-audit-list {
|
||||
max-height: max(210px, calc(100dvh - 360px));
|
||||
overflow: auto;
|
||||
border: 1px solid #e6ecf5;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.runtime-table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.runtime-table-scroll th,
|
||||
.runtime-table-scroll td {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.runtime-table-scroll th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
color: #687792;
|
||||
background: #f7f9fc;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.runtime-table-scroll td:first-child {
|
||||
max-width: 360px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.runtime-table-scroll td:not(:first-child) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.runtime-table-scroll small,
|
||||
.runtime-audit-row small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #7b8ba3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 9px;
|
||||
border-radius: 20px;
|
||||
background: #edf1f7;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-tag.success {
|
||||
color: #248158;
|
||||
background: #e7f6ee;
|
||||
}
|
||||
|
||||
.danger,
|
||||
.runtime-stale {
|
||||
color: #c54343;
|
||||
}
|
||||
|
||||
.runtime-tag.danger {
|
||||
background: #fff0f0;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #a46c13;
|
||||
}
|
||||
|
||||
.runtime-tag.warning {
|
||||
background: #fff5e3;
|
||||
}
|
||||
|
||||
.runtime-audit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.runtime-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.runtime-focus-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.runtime-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
87
frontend/src/views/Dashboard/RuntimeOverview.js
Normal file
87
frontend/src/views/Dashboard/RuntimeOverview.js
Normal file
@ -0,0 +1,87 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
const dayMilliseconds = 86400000;
|
||||
|
||||
export function summarizeRuntime(data, now = Date.now()) {
|
||||
const domains = data.domains || [];
|
||||
const certificates = new Map((data.certificates || []).map((item) => [item.domain?.id, item]));
|
||||
const configuredThreshold = Number(data.settings?.acme?.renewal_near_expiry_days);
|
||||
const threshold = Number.isFinite(configuredThreshold) && configuredThreshold > 0 ? configuredThreshold : 10;
|
||||
const alerts = [];
|
||||
let valid = 0;
|
||||
let expiring = 0;
|
||||
let expired = 0;
|
||||
let missing = 0;
|
||||
for (const domain of domains) {
|
||||
const entry = certificates.get(domain.id);
|
||||
const saved = entry?.certificate?.saved;
|
||||
const expiresAt = Date.parse(saved?.not_after);
|
||||
const startsAt = Date.parse(saved?.not_before);
|
||||
let message;
|
||||
let severity = "warning";
|
||||
if (!saved || !Number.isFinite(expiresAt)) {
|
||||
missing += 1;
|
||||
message = entry?.certificate_error || "暂无可读取证书";
|
||||
} else if (expiresAt <= now) {
|
||||
expired += 1;
|
||||
message = "证书已到期";
|
||||
severity = "danger";
|
||||
} else if (Number.isFinite(startsAt) && startsAt > now) {
|
||||
missing += 1;
|
||||
message = "证书尚未生效";
|
||||
} else {
|
||||
valid += 1;
|
||||
const remaining = Math.ceil((expiresAt - now) / dayMilliseconds);
|
||||
if (expiresAt - now <= threshold * dayMilliseconds) {
|
||||
expiring += 1;
|
||||
message = `证书将在 ${remaining} 天内到期`;
|
||||
}
|
||||
}
|
||||
if (message) alerts.push({ id: domain.id, name: domain.name, message, severity });
|
||||
}
|
||||
alerts.sort((left, right) => Number(right.severity === "danger") - Number(left.severity === "danger"));
|
||||
const tasks = [...(data.tasks || [])].sort((left, right) =>
|
||||
(Date.parse(right.created_at) || 0) - (Date.parse(left.created_at) || 0));
|
||||
return {
|
||||
total: domains.length,
|
||||
valid,
|
||||
expiring,
|
||||
expired,
|
||||
missing,
|
||||
threshold,
|
||||
alerts,
|
||||
autoRenew: domains.filter((item) => item.auto_renew === true).length,
|
||||
configuredServers: (data.servers || []).length,
|
||||
boundDomains: domains.filter((item) => (data.domainServers?.[item.id] || []).length > 0).length,
|
||||
activeTasks: tasks.filter((item) => item.status === "queued" || item.status === "running").length,
|
||||
failedTasks: tasks.filter((item) => item.status === "failed" || item.status === "error").length,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
export function runtimeTime(value) {
|
||||
const date = new Date(value);
|
||||
return value && Number.isFinite(date.getTime()) ? date.toLocaleString("zh-CN", { hour12: false }) : "时间未记录";
|
||||
}
|
||||
|
||||
export function useRuntimeOverview(state) {
|
||||
const detailView = ref("");
|
||||
const clock = ref(Date.now());
|
||||
let timer;
|
||||
const summary = computed(() => summarizeRuntime({
|
||||
domains: state.domains.value,
|
||||
certificates: state.certificates.value,
|
||||
tasks: state.tasks.value,
|
||||
servers: state.servers.value,
|
||||
domainServers: state.domainServers.value,
|
||||
settings: state.settings.value,
|
||||
}, clock.value));
|
||||
const visibleTasks = computed(() => detailView.value === "tasks" ? summary.value.tasks : summary.value.tasks.slice(0, 5));
|
||||
function taskDomain(task) {
|
||||
if (task.task_type === "management_renewal") return "管理页面证书";
|
||||
return state.domains.value.find((item) => item.id === task.domain_id)?.name || "系统任务或已移除域名";
|
||||
}
|
||||
onMounted(() => { timer = window.setInterval(() => { clock.value = Date.now(); }, 60000); });
|
||||
onBeforeUnmount(() => { if (timer) window.clearInterval(timer); });
|
||||
return { detailView, summary, visibleTasks, taskDomain };
|
||||
}
|
||||
122
frontend/src/views/Dashboard/RuntimeOverview.vue
Normal file
122
frontend/src/views/Dashboard/RuntimeOverview.vue
Normal file
@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import HelpTip from "../components/HelpTip/HelpTip.vue";
|
||||
import { runtimeTime, useRuntimeOverview } from "./RuntimeOverview.js";
|
||||
|
||||
const props = defineProps({ state: { type: Object, required: true } });
|
||||
const emit = defineEmits(["navigate"]);
|
||||
const {
|
||||
loading, error, auditLogs, taskTypeLabel, taskStatusLabels,
|
||||
statusClass, displayLabel, auditActionLabel, openTaskDetails, cancelTask,
|
||||
} = props.state;
|
||||
const { detailView, summary, visibleTasks, taskDomain } = useRuntimeOverview(props.state);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="runtime-overview">
|
||||
<p v-if="loading" class="runtime-empty" role="status">正在读取运行状态……</p>
|
||||
<template v-else>
|
||||
<p v-if="error" class="runtime-stale" role="alert">本次数据读取失败,以下内容可能不是最新状态。</p>
|
||||
<div class="runtime-toolbar">
|
||||
<span>{{ detailView ? (detailView === "audit" ? "审计日志 · 最近 20 条" : "任务记录 · 最近 20 条") : "当前托管资源" }}</span>
|
||||
<div class="runtime-actions">
|
||||
<button v-if="detailView" type="button" @click="detailView = ''">返回运行概览</button>
|
||||
<button v-if="detailView !== 'tasks'" type="button" @click="detailView = 'tasks'">任务记录</button>
|
||||
<button v-if="detailView !== 'audit'" type="button" @click="detailView = 'audit'">审计日志</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="!detailView">
|
||||
<div class="runtime-metrics">
|
||||
<article class="runtime-metric">
|
||||
<span>有效期内证书 <HelpTip>根据已保存证书的有效期统计,不代表远程站点已完成部署校验。</HelpTip></span>
|
||||
<strong>{{ summary.valid }}<small> / {{ summary.total }}</small></strong>
|
||||
<p>当前托管域名</p>
|
||||
</article>
|
||||
<article class="runtime-metric" :class="{ attention: summary.alerts.length }">
|
||||
<span>证书待关注</span>
|
||||
<strong>{{ summary.alerts.length }}<small> 个</small></strong>
|
||||
<p>临期 {{ summary.expiring }} · 到期 {{ summary.expired }} · 不可用 {{ summary.missing }}</p>
|
||||
</article>
|
||||
<article class="runtime-metric">
|
||||
<span>自动续期开启 <HelpTip>表示配置已开启,实际续期结果请查看任务记录。</HelpTip></span>
|
||||
<strong>{{ summary.autoRenew }}<small> / {{ summary.total }}</small></strong>
|
||||
<p>托管域名续期覆盖</p>
|
||||
</article>
|
||||
<article class="runtime-metric">
|
||||
<span>进行中任务 <HelpTip>仅统计最近 20 条任务中的排队和执行中任务,不是全部历史任务。</HelpTip></span>
|
||||
<strong>{{ summary.activeTasks }}<small> 项</small></strong>
|
||||
<p>最近 {{ summary.tasks.length }} 条中失败 {{ summary.failedTasks }} 项</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="runtime-focus-grid">
|
||||
<section class="runtime-focus">
|
||||
<div class="runtime-section-heading">
|
||||
<h3>需要关注</h3>
|
||||
<button type="button" @click="emit('navigate', 'domains')">管理域名</button>
|
||||
</div>
|
||||
<p v-if="summary.total === 0" class="runtime-empty">尚未添加托管域名</p>
|
||||
<p v-else-if="summary.alerts.length === 0" class="runtime-clear">当前无临期或不可用证书</p>
|
||||
<ul v-else class="runtime-alerts">
|
||||
<li v-for="item in summary.alerts.slice(0, 3)" :key="item.id">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span :class="item.severity">{{ item.message }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<small v-if="summary.alerts.length > 3">另有 {{ summary.alerts.length - 3 }} 项,请在域名配置中查看。</small>
|
||||
</section>
|
||||
<section class="runtime-focus">
|
||||
<div class="runtime-section-heading">
|
||||
<h3>部署配置 <HelpTip>这里只展示部署配置覆盖,不推断服务器在线状态或证书推送结果。</HelpTip></h3>
|
||||
<button type="button" @click="emit('navigate', 'servers')">管理服务器</button>
|
||||
</div>
|
||||
<dl class="runtime-deployment">
|
||||
<div><dt>已配置服务器</dt><dd>{{ summary.configuredServers }} 台</dd></div>
|
||||
<div><dt>已绑定部署域名</dt><dd>{{ summary.boundDomains }} / {{ summary.total }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<section v-if="detailView !== 'audit'" class="runtime-tasks">
|
||||
<div class="runtime-section-heading">
|
||||
<h3>{{ detailView === "tasks" ? "任务记录" : "最近任务" }}</h3>
|
||||
<span>显示 {{ visibleTasks.length }} 条{{ detailView ? "" : " · 最多 5 条" }}</span>
|
||||
</div>
|
||||
<p v-if="visibleTasks.length === 0" class="runtime-empty">暂无任务记录</p>
|
||||
<div v-else class="runtime-table-scroll" tabindex="0" aria-label="证书任务列表">
|
||||
<table>
|
||||
<thead><tr><th scope="col">任务 / 对象</th><th scope="col">状态</th><th scope="col">创建时间</th><th scope="col">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in visibleTasks" :key="item.id">
|
||||
<td><strong>{{ taskTypeLabel(item.task_type) }}</strong><small>{{ taskDomain(item) }}</small></td>
|
||||
<td><span class="runtime-tag" :class="statusClass(item.status)">{{ displayLabel(taskStatusLabels, item.status) }}</span></td>
|
||||
<td>{{ runtimeTime(item.created_at) }}</td>
|
||||
<td>
|
||||
<button type="button" @click="openTaskDetails(item)">查看详情</button>
|
||||
<button
|
||||
v-if="item.task_type !== 'management_renewal' && (item.status === 'queued' || item.status === 'running')"
|
||||
type="button"
|
||||
@click="cancelTask(item)"
|
||||
>取消任务</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section v-else class="runtime-audit" aria-label="审计日志">
|
||||
<p v-if="auditLogs.length === 0" class="runtime-empty">暂无审计日志</p>
|
||||
<div v-else class="runtime-audit-list" tabindex="0" aria-label="最近审计记录">
|
||||
<article v-for="item in auditLogs" :key="item.id" class="runtime-audit-row">
|
||||
<div>
|
||||
<strong>{{ auditActionLabel(item.action) }}</strong>
|
||||
<small>{{ item.message || item.resource_type }}</small>
|
||||
</div>
|
||||
<span v-if="item.status === 'success' || item.status === 'failed'" class="runtime-tag" :class="statusClass(item.status)">{{ item.status === "success" ? "成功" : "失败" }}</span>
|
||||
<span v-else class="runtime-tag" :class="statusClass(item.status)">{{ displayLabel(taskStatusLabels, item.status) }}</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="./RuntimeOverview.css"></style>
|
||||
Loading…
Reference in New Issue
Block a user