diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index b3420c4..54721a2 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -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('
", start) + "
".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 () => { diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index 6c6339e..1801506 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -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
-

运行概览

-

最近活动

-
- 最近 20 条记录 -
-
-
-

证书状态

-
暂无证书记录
-
-
-
- {{ item.domain.name }} - 到期:{{ formatDate(item.certificate.saved.not_after) }} - {{ item.certificate_error || "读取失败" }} -
- 已保存 - 未保存 -
-
-
-
-

任务记录

-
暂无任务记录
-
-
-
- {{ taskTypeLabel(item.task_type) }} - {{ item.message || "无附加信息" }} -
-
- {{ displayLabel(taskStatusLabels, item.status) }} - - -
-
-
-
-
-

审计日志

-
暂无审计日志
-
-
-
- {{ auditActionLabel(item.action) }} - {{ item.message || item.resource_type }} -
- {{ item.status === "success" ? "成功" : "失败" }} - - {{ displayLabel(taskStatusLabels, item.status) }} - -
-
+

运行概览

+ 证书有效期 · 自动续期 · 任务执行
+
diff --git a/frontend/src/views/Dashboard/DashboardNavigation.js b/frontend/src/views/Dashboard/DashboardNavigation.js index cc3562c..815c445 100644 --- a/frontend/src/views/Dashboard/DashboardNavigation.js +++ b/frontend/src/views/Dashboard/DashboardNavigation.js @@ -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 私钥" }, diff --git a/frontend/src/views/Dashboard/RuntimeOverview.css b/frontend/src/views/Dashboard/RuntimeOverview.css new file mode 100644 index 0000000..79675c9 --- /dev/null +++ b/frontend/src/views/Dashboard/RuntimeOverview.css @@ -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; + } +} diff --git a/frontend/src/views/Dashboard/RuntimeOverview.js b/frontend/src/views/Dashboard/RuntimeOverview.js new file mode 100644 index 0000000..101e34c --- /dev/null +++ b/frontend/src/views/Dashboard/RuntimeOverview.js @@ -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 }; +} diff --git a/frontend/src/views/Dashboard/RuntimeOverview.vue b/frontend/src/views/Dashboard/RuntimeOverview.vue new file mode 100644 index 0000000..4c8a735 --- /dev/null +++ b/frontend/src/views/Dashboard/RuntimeOverview.vue @@ -0,0 +1,122 @@ + + + + +