diff --git a/src/public/locales/ar-SA.json b/src/public/locales/ar-SA.json
index 1d9c998df8..6ff88a7afa 100644
--- a/src/public/locales/ar-SA.json
+++ b/src/public/locales/ar-SA.json
@@ -6136,6 +6136,9 @@
}
}
},
+ "normal": "طبيعي",
+ "memoryUsage": "استخدام الذاكرة",
+ "realtimeLogs": "السجلات المباشرة",
"update": {
"failed": "فشل طلب التحديث",
"now": "قم بالتحديث وإعادة التشغيل الآن",
diff --git a/src/public/locales/de-DE.json b/src/public/locales/de-DE.json
index 330aed3a56..ecf8422ab5 100644
--- a/src/public/locales/de-DE.json
+++ b/src/public/locales/de-DE.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "Normal",
+ "memoryUsage": "Speichernutzung",
+ "realtimeLogs": "Live-Protokoll",
"update": {
"failed": "Die Aktualisierungsanforderung ist fehlgeschlagen",
"now": "Jetzt aktualisieren und neu starten",
diff --git a/src/public/locales/emoji.json b/src/public/locales/emoji.json
index ba69c855aa..68ef773e3f 100644
--- a/src/public/locales/emoji.json
+++ b/src/public/locales/emoji.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "✅",
+ "memoryUsage": "🧠📈",
+ "realtimeLogs": "📜🔴",
"update": {
"failed": "❌🔄",
"now": "🆕🔄▶️",
diff --git a/src/public/locales/en-UK.json b/src/public/locales/en-UK.json
index 7c5fe0d28c..92f7e0e40a 100644
--- a/src/public/locales/en-UK.json
+++ b/src/public/locales/en-UK.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "Normal",
+ "memoryUsage": "Memory usage",
+ "realtimeLogs": "Live logs",
"update": {
"failed": "Update request failed",
"now": "Update and restart now",
diff --git a/src/public/locales/es-ES.json b/src/public/locales/es-ES.json
index 10bb8bd5fe..1b84ef3c65 100644
--- a/src/public/locales/es-ES.json
+++ b/src/public/locales/es-ES.json
@@ -6132,6 +6132,9 @@
}
}
},
+ "normal": "Normal",
+ "memoryUsage": "Uso de memoria",
+ "realtimeLogs": "Registros en vivo",
"update": {
"failed": "Error en la solicitud de actualización",
"now": "Actualiza y reinicia ahora",
diff --git a/src/public/locales/fr-FR.json b/src/public/locales/fr-FR.json
index 1a3edd1be4..f46c1a6291 100644
--- a/src/public/locales/fr-FR.json
+++ b/src/public/locales/fr-FR.json
@@ -6132,6 +6132,9 @@
}
}
},
+ "normal": "Normal",
+ "memoryUsage": "Utilisation de la mémoire",
+ "realtimeLogs": "Journaux en direct",
"update": {
"failed": "La demande de mise à jour a échoué",
"now": "Mettre à jour et redémarrer maintenant",
diff --git a/src/public/locales/hi-IN.json b/src/public/locales/hi-IN.json
index 90022da54e..6e35a0b438 100644
--- a/src/public/locales/hi-IN.json
+++ b/src/public/locales/hi-IN.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "सामान्य",
+ "memoryUsage": "मेमोरी उपयोग",
+ "realtimeLogs": "लाइव लॉग",
"update": {
"failed": "अद्यतन अनुरोध विफल रहा",
"now": "अभी अपडेट करें और पुनः प्रारंभ करें",
diff --git a/src/public/locales/is-IS.json b/src/public/locales/is-IS.json
index 6e323fcd5f..b9faed6398 100644
--- a/src/public/locales/is-IS.json
+++ b/src/public/locales/is-IS.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "Eðlilegt",
+ "memoryUsage": "Minnisnotkun",
+ "realtimeLogs": "Lifandi annáll",
"update": {
"failed": "Uppfærslubeiðni mistókst",
"now": "Uppfærðu og endurræstu núna",
diff --git a/src/public/locales/it-IT.json b/src/public/locales/it-IT.json
index b1d55858b7..632211fd28 100644
--- a/src/public/locales/it-IT.json
+++ b/src/public/locales/it-IT.json
@@ -6132,6 +6132,9 @@
}
}
},
+ "normal": "Normale",
+ "memoryUsage": "Utilizzo memoria",
+ "realtimeLogs": "Log in tempo reale",
"update": {
"failed": "Richiesta di aggiornamento non riuscita",
"now": "Aggiorna e riavvia ora",
diff --git a/src/public/locales/ja-JP.json b/src/public/locales/ja-JP.json
index 186612e259..5741697e02 100644
--- a/src/public/locales/ja-JP.json
+++ b/src/public/locales/ja-JP.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "正常",
+ "memoryUsage": "メモリ使用率",
+ "realtimeLogs": "リアルタイムログ",
"update": {
"failed": "更新リクエストが失敗しました",
"now": "今すぐ更新して再起動",
diff --git a/src/public/locales/ko-KR.json b/src/public/locales/ko-KR.json
index 1499a88a28..04122acd9e 100644
--- a/src/public/locales/ko-KR.json
+++ b/src/public/locales/ko-KR.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "정상",
+ "memoryUsage": "메모리 사용률",
+ "realtimeLogs": "실시간 로그",
"update": {
"failed": "업데이트 요청 실패",
"now": "지금 업데이트하고 다시 시작하세요",
diff --git a/src/public/locales/lzh.json b/src/public/locales/lzh.json
index 57facad1f2..8165d7a530 100644
--- a/src/public/locales/lzh.json
+++ b/src/public/locales/lzh.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "正常",
+ "memoryUsage": "記憶體用量",
+ "realtimeLogs": "即時誌",
"update": {
"failed": "更之請未遂",
"now": "當即更並重啟",
diff --git a/src/public/locales/nl-NL.json b/src/public/locales/nl-NL.json
index ca9b0b8611..014c76ae1c 100644
--- a/src/public/locales/nl-NL.json
+++ b/src/public/locales/nl-NL.json
@@ -6132,6 +6132,9 @@
}
}
},
+ "normal": "Normaal",
+ "memoryUsage": "Geheugengebruik",
+ "realtimeLogs": "Live-logboek",
"update": {
"failed": "Updateverzoek is mislukt",
"now": "Update en start nu opnieuw",
diff --git a/src/public/locales/pt-PT.json b/src/public/locales/pt-PT.json
index 4561171e2b..872594c248 100644
--- a/src/public/locales/pt-PT.json
+++ b/src/public/locales/pt-PT.json
@@ -6132,6 +6132,9 @@
}
}
},
+ "normal": "Normal",
+ "memoryUsage": "Utilização de memória",
+ "realtimeLogs": "Registos em direto",
"update": {
"failed": "Falha na solicitação de atualização",
"now": "Atualize e reinicie agora",
diff --git a/src/public/locales/ru-RU.json b/src/public/locales/ru-RU.json
index bdfd7d9a69..6a9383a535 100644
--- a/src/public/locales/ru-RU.json
+++ b/src/public/locales/ru-RU.json
@@ -6134,6 +6134,9 @@
}
}
},
+ "normal": "Норма",
+ "memoryUsage": "Использование памяти",
+ "realtimeLogs": "Журнал в реальном времени",
"update": {
"failed": "Запрос на обновление не выполнен",
"now": "Обновите и перезапустите сейчас",
diff --git a/src/public/locales/uk-UA.json b/src/public/locales/uk-UA.json
index 9c9c137038..f30816868b 100644
--- a/src/public/locales/uk-UA.json
+++ b/src/public/locales/uk-UA.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "Норма",
+ "memoryUsage": "Використання пам’яті",
+ "realtimeLogs": "Журнал у реальному часі",
"update": {
"failed": "Помилка запиту на оновлення",
"now": "Оновіть і перезапустіть зараз",
diff --git a/src/public/locales/vi-VN.json b/src/public/locales/vi-VN.json
index ff5d73a9b9..6cc20c0ca7 100644
--- a/src/public/locales/vi-VN.json
+++ b/src/public/locales/vi-VN.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "Bình thường",
+ "memoryUsage": "Mức sử dụng bộ nhớ",
+ "realtimeLogs": "Nhật ký thời gian thực",
"update": {
"failed": "Yêu cầu cập nhật không thành công",
"now": "Cập nhật và khởi động lại ngay",
diff --git a/src/public/locales/zh-CN.json b/src/public/locales/zh-CN.json
index 2745d7c4fa..918ec9d306 100644
--- a/src/public/locales/zh-CN.json
+++ b/src/public/locales/zh-CN.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "正常",
+ "memoryUsage": "内存使用率",
+ "realtimeLogs": "实时日志",
"update": {
"failed": "更新请求失败",
"now": "立即更新并重启",
diff --git a/src/public/locales/zh-TW.json b/src/public/locales/zh-TW.json
index 6eb8343ed1..a10e4ee1ab 100644
--- a/src/public/locales/zh-TW.json
+++ b/src/public/locales/zh-TW.json
@@ -6126,6 +6126,9 @@
}
}
},
+ "normal": "正常",
+ "memoryUsage": "記憶體使用率",
+ "realtimeLogs": "即時日誌",
"update": {
"failed": "更新請求失敗",
"now": "立即更新並重啟",
diff --git a/src/public/pages/log_viewer/index.css b/src/public/pages/log_viewer/index.css
index 739defe8dd..b63c331f3b 100644
--- a/src/public/pages/log_viewer/index.css
+++ b/src/public/pages/log_viewer/index.css
@@ -3,6 +3,43 @@
font-size: 12.5px;
}
+html[data-embedded="debug-info"],
+html[data-embedded="debug-info"] body,
+html[data-embedded="debug-info"] main {
+ background: transparent;
+}
+
+html[data-embedded="debug-info"] main {
+ padding: 0;
+}
+
+html[data-embedded="debug-info"] .log-container {
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ background: transparent;
+}
+
+html[data-embedded="debug-info"] .log-toolbar {
+ min-height: 2.75rem;
+ padding: .4rem .75rem;
+ border-bottom: var(--border) solid var(--border-color);
+ background: color-mix(in oklch, var(--surface-raised) 72%, transparent);
+}
+
+html[data-embedded="debug-info"] #backend-log-list {
+ background: var(--surface-sunken);
+}
+
+html[data-embedded="debug-info"] .log-row {
+ min-height: 1.7rem;
+ padding: .2rem .9rem;
+}
+
+html[data-embedded="debug-info"] .repl-panel {
+ display: none;
+}
+
.log-row {
display: flex;
align-items: flex-start;
diff --git a/src/public/pages/log_viewer/index.mjs b/src/public/pages/log_viewer/index.mjs
index 5028e57538..52f963fb65 100644
--- a/src/public/pages/log_viewer/index.mjs
+++ b/src/public/pages/log_viewer/index.mjs
@@ -9,6 +9,9 @@ import { createLogsWs, openSource } from './endpoints.mjs'
import { renderLogItem, createLogToolbar, entryMatchesFilter } from './log.mjs'
import { initRepl, mountReplPanel } from './repl/index.mjs'
+if (new URLSearchParams(location.search).get('embedded') === 'debug-info')
+ document.documentElement.dataset.embedded = 'debug-info'
+
applyTheme()
await initTranslations('log_viewer')
diff --git a/src/public/parts/shells/debug_info/public/index.css b/src/public/parts/shells/debug_info/public/index.css
new file mode 100644
index 0000000000..db3f41c215
--- /dev/null
+++ b/src/public/parts/shells/debug_info/public/index.css
@@ -0,0 +1,366 @@
+.debug-info-page {
+ --debug-accent: color-mix(in oklch, var(--color-success) 68%, var(--color-warning));
+ --debug-accent-content: color-mix(in oklch, var(--color-success-content) 78%, var(--color-warning-content));
+
+ padding: clamp(1rem, 3vw, 2.5rem);
+ background: oklch(from var(--surface) calc(l * .72) c h);
+ color: var(--text);
+ font-family: var(--font-body);
+}
+
+.debug-info-main {
+ display: grid;
+ gap: 1.25rem;
+ width: min(100%, 72rem);
+ margin: 0 auto;
+}
+
+.debug-info-header,
+.panel-heading,
+.version-heading,
+.version-panel,
+.panel-toggle,
+.test-status-meta {
+ display: flex;
+ align-items: center;
+}
+
+.debug-info-header,
+.panel-heading,
+.panel-toggle {
+ justify-content: space-between;
+}
+
+.debug-info-header {
+ gap: 2rem;
+ padding: .5rem 0 .75rem;
+}
+
+.debug-info-header h1 {
+ font-family: var(--font-heading);
+ font-size: clamp(1.6rem, 3vw, 2.2rem);
+ font-weight: 800;
+ letter-spacing: -.045em;
+}
+
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: .4rem;
+ color: var(--text-muted);
+ font-size: .8rem;
+ font-weight: 700;
+ padding: .32rem .65rem;
+ border-radius: var(--radius-field);
+ background: color-mix(in srgb, currentcolor 12%, transparent);
+}
+
+.status-badge[data-state="ok"],
+.status-badge[data-state="running"],
+.connectivity-status[data-state="ok"] {
+ color: var(--debug-accent-content);
+ background: var(--debug-accent);
+}
+
+.status-badge[data-state="outdated"],
+.connectivity-status[data-state="error"] {
+ color: var(--color-error-content);
+ background: var(--color-error);
+}
+
+.status-badge[data-state="checking"],
+.status-badge[data-state="failed"] {
+ color: var(--color-warning-content);
+ background: var(--color-warning);
+}
+
+.copy-button,
+.update-button {
+ gap: .55rem;
+}
+
+.copy-button {
+ color: var(--debug-accent-content);
+ border-color: var(--debug-accent);
+ background: var(--debug-accent);
+}
+
+.copy-button .text-icon,
+.update-button .text-icon,
+.panel-heading .text-icon {
+ width: 1.15rem;
+ height: 1.15rem;
+}
+
+.debug-info-panel {
+ background: var(--surface);
+ border: var(--border) solid var(--border-color);
+ border-radius: var(--radius-box);
+ box-shadow: var(--shadow-sm);
+ padding: clamp(1rem, 2vw, 1.4rem);
+}
+
+.panel-heading {
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.panel-heading h2,
+.version-heading h2,
+.panel-title,
+.system-overview h2 {
+ font-family: var(--font-heading);
+ font-size: 1rem;
+ font-weight: 800;
+}
+
+.version-panel {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: clamp(1rem, 3vw, 2.25rem);
+ min-height: 5rem;
+}
+
+.version-heading {
+ gap: .8rem;
+}
+
+.version-details {
+ display: grid;
+ gap: .25rem;
+ min-width: 0;
+ padding-inline: clamp(0rem, 2vw, 1.5rem);
+ border-inline: var(--border) solid var(--border-color);
+ color: var(--text-muted);
+ font-size: .8rem;
+}
+
+.version-value {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: .75rem;
+}
+
+.version-value code {
+ overflow: hidden;
+ color: var(--text);
+ font-family: var(--font-code);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.system-panel {
+ display: grid;
+ grid-template-columns: minmax(17rem, 1fr) minmax(24rem, 1.1fr);
+ gap: clamp(1rem, 4vw, 3rem);
+ min-height: 14rem;
+}
+
+.system-overview {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-height: 11rem;
+ padding-right: clamp(0rem, 2vw, 1.5rem);
+ border-right: var(--border) solid var(--border-strong);
+}
+
+.memory-summary {
+ display: flex;
+ flex-direction: column;
+ color: var(--text-muted);
+ font-size: .8rem;
+}
+
+.memory-summary strong {
+ color: var(--text);
+ font-family: var(--font-heading);
+ font-size: clamp(3.75rem, 7vw, 5rem);
+ font-weight: 700;
+ line-height: .9;
+ letter-spacing: -.08em;
+}
+
+.memory-summary .progress {
+ width: min(100%, 22rem);
+ height: .45rem;
+ margin-top: 1rem;
+ color: var(--debug-accent);
+}
+
+.table-scroll {
+ overflow-x: auto;
+}
+
+.debug-info-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: .88rem;
+}
+
+.debug-info-table tr + tr {
+ border-top: var(--border) solid var(--border-color);
+}
+
+.debug-info-table th,
+.debug-info-table td {
+ padding: .75rem .6rem;
+ text-align: left;
+ vertical-align: middle;
+}
+
+.debug-info-table th {
+ font-weight: 700;
+}
+
+.system-key,
+.service-name {
+ display: inline-flex;
+ align-items: center;
+ gap: .65rem;
+}
+
+.system-key .text-icon,
+.service-name .text-icon {
+ width: 1.2rem;
+ height: 1.2rem;
+ flex: 0 0 auto;
+}
+
+.system-value {
+ color: var(--text-muted);
+}
+
+.loading-cell {
+ color: var(--text-muted);
+ text-align: center;
+}
+
+.panel-toggle {
+ width: 100%;
+ gap: 1rem;
+ cursor: pointer;
+ text-align: left;
+}
+
+.test-status-meta {
+ gap: .75rem;
+}
+
+.panel-chevron {
+ width: 1.25rem;
+ height: 1.25rem;
+ transition: transform var(--duration-hover) var(--ease-standard);
+}
+
+.panel-chevron.rotate-180 {
+ transform: rotate(180deg);
+}
+
+.test-status-list {
+ margin-top: .75rem;
+}
+
+.connectivity-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1.25rem;
+}
+
+.connectivity-table {
+ min-width: 30rem;
+}
+
+.connectivity-table th {
+ width: 38%;
+}
+
+.connectivity-table td:nth-child(2) {
+ width: 6.5rem;
+}
+
+.connectivity-table td:nth-child(3),
+.connectivity-table td:last-child {
+ width: 1%;
+ white-space: nowrap;
+}
+
+.latency-sparkline {
+ display: block;
+ width: 5.5rem;
+ height: 1.6rem;
+ color: var(--text-subtle);
+}
+
+.connectivity-status[data-state="ok"].latency-sparkline {
+ color: var(--debug-accent);
+ background: transparent;
+}
+
+.connectivity-status[data-state="error"].latency-sparkline {
+ color: var(--color-error);
+ background: transparent;
+}
+
+.connectivity-table .status-badge {
+ justify-content: center;
+ min-width: 3.4rem;
+ font-size: .75rem;
+}
+
+.latency-value {
+ color: var(--text);
+ font-family: var(--font-code);
+}
+
+.log-panel {
+ overflow: hidden;
+ padding-bottom: 0;
+}
+
+.debug-log-frame {
+ display: block;
+ width: calc(100% + 2 * clamp(1rem, 2vw, 1.4rem));
+ height: clamp(11rem, 24vh, 16rem);
+ margin-top: 1rem;
+ margin-left: calc(-1 * clamp(1rem, 2vw, 1.4rem));
+ border: 0;
+ border-top: var(--border) solid var(--border-color);
+}
+
+@media (max-width: 52rem) {
+ .version-panel,
+ .system-panel,
+ .connectivity-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .version-details {
+ padding: .75rem 0;
+ border-inline: 0;
+ border-block: var(--border) solid var(--border-color);
+ }
+
+ .system-overview {
+ min-height: auto;
+ padding-right: 0;
+ padding-bottom: 1rem;
+ border-right: 0;
+ border-bottom: var(--border) solid var(--border-strong);
+ }
+
+ .memory-summary {
+ margin-top: 2rem;
+ }
+}
+
+@media (max-width: 36rem) {
+ .debug-info-header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .copy-button {
+ width: 100%;
+ }
+}
diff --git a/src/public/parts/shells/debug_info/public/index.html b/src/public/parts/shells/debug_info/public/index.html
index 2b2680528b..0ef3d1e1f5 100644
--- a/src/public/parts/shells/debug_info/public/index.html
+++ b/src/public/parts/shells/debug_info/public/index.html
@@ -16,99 +16,97 @@
+
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+

+
+
-
-
-
-
-
-
+
+
+
+

+
+
-
+
-
-
-
-
+
+
+
+
-
+
+
diff --git a/src/public/parts/shells/debug_info/public/index.mjs b/src/public/parts/shells/debug_info/public/index.mjs
index b5f006bc14..40c0abdf6a 100644
--- a/src/public/parts/shells/debug_info/public/index.mjs
+++ b/src/public/parts/shells/debug_info/public/index.mjs
@@ -6,6 +6,7 @@ import { onServerEvent } from '/scripts/endpoints/server_events.mjs'
import { ping } from '/scripts/endpoints/base.mjs'
import { createTestStatusWs, getSystemInfo, postRestart } from './src/endpoints.mjs'
import { mountTemplate, renderTemplate } from './templates.mjs'
+import { recordLatency, redrawLatencySparklines } from './src/latency_sparkline.mjs'
applyTheme()
await initTranslations('debug_info')
@@ -40,9 +41,34 @@ let isUpToDate = null
* @param {number} bytes - 字节数。
* @returns {string} 以 GiB 为单位保留两位小数的字符串。
*/
-const bytesToGiB = bytes => (bytes / 1024 ** 3).toFixed(2)
+const bytesToGiB = bytes => Number.isFinite(bytes) && bytes >= 0 ? (bytes / 1024 ** 3).toFixed(2) : 'Unknown'
+
+function getMemoryStats(memory = {}) {
+ const total = Number(memory.total)
+ const free = Number(memory.free)
+ if (!Number.isFinite(total) || !Number.isFinite(free) || total <= 0) return { used: null, percent: null }
+ const used = Math.max(0, total - Math.min(free, total))
+ return { used, percent: Math.round(used / total * 100) }
+}
+
+function setBadgeState(element, state) {
+ element.dataset.state = state
+}
const FOUNT_REPO_COMMITS = 'https://api.github.com/repos/steve02081504/fount/commits'
+const ICONIFY_LINE = 'https://api.iconify.design/line-md'
+const SERVICE_ICONS = new Map([
+ ['npm Registry', 'square'],
+ ['Deno Land', 'engine'],
+ ['jsDelivr', 'cloud'],
+ ['JSR', 'list-3-filled'],
+ ['fount Network', 'link'],
+ ['fount Server', 'computer'],
+ ['esm.sh', 'cloud-alt-braces'],
+ ['Iconify', 'image'],
+ ['fount Public', 'external-link'],
+])
+const serviceIcon = name => `${ICONIFY_LINE}/${SERVICE_ICONS.get(name) || 'square'}.svg`
/**
* 获取 GitHub 上指定分支的最新提交 SHA。
@@ -75,11 +101,11 @@ async function fetchVersionInfo() {
debugData.version.remote = remoteVer
isUpToDate = localVer === remoteVer
- versionIndicator.className = `badge badge-lg ${isUpToDate ? 'badge-success' : 'badge-error'} gap-2`
+ setBadgeState(versionIndicator, isUpToDate ? 'ok' : 'outdated')
versionIndicator.dataset.i18n = isUpToDate ? 'debug_info.versionStatus.upToDate' : 'debug_info.versionStatus.outdated'
} catch (error) {
console.error('Version check failed:', error)
- versionIndicator.className = 'badge badge-lg badge-warning gap-2'
+ setBadgeState(versionIndicator, 'failed')
versionIndicator.dataset.i18n = 'debug_info.versionStatus.checkFailed'
isUpToDate = null
}
@@ -97,17 +123,24 @@ async function fetchSystemInfo() {
const data = await getSystemInfo()
debugData.system = data
- const { os, cpu, memory, connectivity } = data
+ const { os = {}, cpu = {}, memory = {}, connectivity = [] } = data
+ const { percent } = getMemoryStats(memory)
+ document.getElementById('memory-percent').textContent = percent == null ? '--%' : `${percent}%`
+ document.getElementById('memory-progress').value = percent ?? 0
const rows = [
- { key: 'OS', val: `${os.platform} ${os.release} (${os.arch})` },
- { key: 'CPU', val: `${cpu.model} (${cpu.cores} cores) @ ${cpu.speed}MHz` },
- { key: 'Memory', val: `Total: ${bytesToGiB(memory.total)} GB / Free: ${bytesToGiB(memory.free)} GB` },
+ { key: 'OS', icon: `${ICONIFY_LINE}/monitor.svg`, val: `${os.platform} ${os.release} (${os.arch})` },
+ { key: 'CPU', icon: `${ICONIFY_LINE}/cog.svg`, val: `${cpu.model} (${cpu.cores} cores) @ ${cpu.speed}MHz` },
+ { key: 'Memory', icon: `${ICONIFY_LINE}/engine.svg`, val: `Total: ${bytesToGiB(memory.total)} GB / Free: ${bytesToGiB(memory.free)} GB` },
]
await mountTemplate(systemInfoTable, 'system_info_table', { rows })
debugData.connectivity.backend = connectivity
- await mountTemplate(backendChecks, 'connectivity_list', { checks: connectivity })
+ for (const check of connectivity) recordLatency(check.id || check.name, check.duration)
+ await mountTemplate(backendChecks, 'connectivity_list', {
+ checks: connectivity.map(check => ({ ...check, icon: serviceIcon(check.name) })),
+ })
+ redrawLatencySparklines(backendChecks)
} catch (error) {
console.error('System info fetch failed:', error)
systemInfoTable.innerHTML = '
|
'
@@ -119,26 +152,30 @@ async function fetchSystemInfo() {
*/
async function checkFrontendConnectivity() {
const checks = [
- { id: 'check-fount-server', name: 'fount Server', url: '/api/ping' },
- { id: 'check-esm', name: 'esm.sh', url: 'https://esm.sh' },
- { id: 'check-jsdelivr', name: 'jsDelivr', url: 'https://cdn.jsdelivr.net' },
- { id: 'check-iconify', name: 'Iconify', url: 'https://api.iconify.design' },
- { id: 'check-fount-public', name: 'fount Public', url: 'https://steve02081504.github.io/fount' }
+ { id: 'check-fount-server', name: 'fount Server', icon: serviceIcon('fount Server'), url: '/api/ping' },
+ { id: 'check-esm', name: 'esm.sh', icon: serviceIcon('esm.sh'), url: 'https://esm.sh' },
+ { id: 'check-jsdelivr', name: 'jsDelivr', icon: serviceIcon('jsDelivr'), url: 'https://cdn.jsdelivr.net' },
+ { id: 'check-iconify', name: 'Iconify', icon: serviceIcon('Iconify'), url: 'https://api.iconify.design' },
+ { id: 'check-fount-public', name: 'fount Public', icon: serviceIcon('fount Public'), url: 'https://steve02081504.github.io/fount' }
]
await mountTemplate(frontendChecks, 'connectivity_list', { checks })
-
+ const results = []
for (const check of checks) {
const start = Date.now()
- let status = 'error', duration = 0
+ let status = 'error', duration
try {
await fetch(check.url, { method: 'HEAD', mode: 'no-cors', cache: 'no-store' })
status = 'ok'
duration = Date.now() - start
} catch { /* unreachable */ }
- debugData.connectivity.frontend.push({ ...check, status, duration })
- document.getElementById(check.id).replaceWith(await renderTemplate('connectivity_item', { ...check, status, duration }))
+ const result = { ...check, status, duration }
+ results.push(result)
+ recordLatency(check.id || check.name, duration)
+ document.getElementById(check.id).replaceWith(await renderTemplate('connectivity_item', result))
}
+ debugData.connectivity.frontend = results
+ redrawLatencySparklines(frontendChecks)
}
let testStatusOpen = false
@@ -176,7 +213,7 @@ async function renderTestStatus(status) {
const online = status?.online === true
testStatusCard.classList.toggle('hidden', !online)
if (!online) return
- testStatusBadge.className = `badge badge-lg gap-2 ${status.active ? 'badge-success' : 'badge-ghost'}`
+ setBadgeState(testStatusBadge, status.active ? 'running' : 'idle')
testStatusBadge.dataset.i18n = status.active ? 'debug_info.testStatus.running' : 'debug_info.testStatus.idle'
if (!status.active) {
testStatusList.replaceChildren()
@@ -326,8 +363,8 @@ function stopTestStatusStream() {
testStatusWs = null
}
-const UPDATE_ICON = 'https://api.iconify.design/mdi/update.svg'
-const LOADING_ICON = 'https://api.iconify.design/line-md/loading-twotone-loop.svg'
+const UPDATE_ICON = 'https://api.iconify.design/line-md/backup-restore.svg'
+const LOADING_ICON = 'https://api.iconify.design/line-md/loading-loop.svg'
const UPTODATE_ICON = 'https://api.iconify.design/line-md/confirm.svg'
/**
@@ -412,7 +449,7 @@ let pollTimer = null
*/
async function pollVersionInfo() {
lastVersionCheckTime = Date.now()
- await fetchVersionInfo()
+ await Promise.all([fetchVersionInfo(), fetchSystemInfo(), checkFrontendConnectivity()])
}
/**
@@ -445,7 +482,9 @@ document.addEventListener('visibilitychange', () => {
if (!document.hidden) startPollTimer()
+const redrawSparklines = () => redrawLatencySparklines()
+new ResizeObserver(redrawSparklines).observe(document.documentElement)
+new MutationObserver(redrawSparklines).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
+
pollVersionInfo()
-fetchSystemInfo()
-checkFrontendConnectivity()
startTestStatusStream()
diff --git a/src/public/parts/shells/debug_info/public/src/latency_sparkline.mjs b/src/public/parts/shells/debug_info/public/src/latency_sparkline.mjs
new file mode 100644
index 0000000000..0e3285ed82
--- /dev/null
+++ b/src/public/parts/shells/debug_info/public/src/latency_sparkline.mjs
@@ -0,0 +1,73 @@
+const MAX_SAMPLES = 10
+const histories = new Map()
+
+/**
+ * 记录一次真实的连通性延迟。
+ * @param {string} key 稳定的检查标识。
+ * @param {number} duration 延迟毫秒数。
+ */
+export function recordLatency(key, duration) {
+ if (!Number.isFinite(duration) || duration < 0) return
+ const samples = histories.get(key) || []
+ samples.push(duration)
+ if (samples.length > MAX_SAMPLES) samples.shift()
+ histories.set(key, samples)
+}
+
+/**
+ * 根据已有样本绘制单个延迟趋势图。重绘不会新增样本。
+ * @param {HTMLCanvasElement} canvas 目标画布。
+ * @param {string} key 稳定的检查标识。
+ */
+export function drawLatencySparkline(canvas, key) {
+ const samples = histories.get(key) || []
+ const context = canvas.getContext('2d')
+ if (!context) return
+
+ const ratio = window.devicePixelRatio || 1
+ const { width, height } = canvas.getBoundingClientRect()
+ canvas.width = Math.max(1, Math.round(width * ratio))
+ canvas.height = Math.max(1, Math.round(height * ratio))
+ context.scale(ratio, ratio)
+ context.clearRect(0, 0, width, height)
+ if (!samples.length) return
+
+ const color = getComputedStyle(canvas).color
+ context.strokeStyle = color
+ context.fillStyle = color
+ context.lineWidth = Math.max(1.5, Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--border')) || 1)
+ context.lineJoin = 'round'
+ context.lineCap = 'round'
+ if (samples.length < 3) {
+ context.beginPath()
+ context.arc(width / 2, height / 2, context.lineWidth, 0, Math.PI * 2)
+ context.fill()
+ return
+ }
+
+ const min = Math.min(...samples)
+ const max = Math.max(...samples)
+ const range = max - min
+ const points = samples.map((value, index) => ({
+ x: index * width / (samples.length - 1),
+ y: range ? height - 2 - (value - min) / range * (height - 4) : height / 2,
+ }))
+ context.beginPath()
+ context.moveTo(points[0].x, points[0].y)
+ for (let index = 1; index < points.length - 1; index++) {
+ const point = points[index], next = points[index + 1]
+ context.quadraticCurveTo(point.x, point.y, (point.x + next.x) / 2, (point.y + next.y) / 2)
+ }
+ const last = points.at(-1)
+ context.lineTo(last.x, last.y)
+ context.stroke()
+}
+
+/**
+ * 重绘指定根节点中的全部趋势图。
+ * @param {ParentNode} root 查询根节点。
+ */
+export function redrawLatencySparklines(root = document) {
+ for (const canvas of root.querySelectorAll('.latency-sparkline'))
+ drawLatencySparkline(canvas, canvas.dataset.latencyKey)
+}
diff --git a/src/public/parts/shells/debug_info/public/templates/connectivity_item.html b/src/public/parts/shells/debug_info/public/templates/connectivity_item.html
index 4d60203f41..1c132974c1 100644
--- a/src/public/parts/shells/debug_info/public/templates/connectivity_item.html
+++ b/src/public/parts/shells/debug_info/public/templates/connectivity_item.html
@@ -1,6 +1,11 @@
-
- ${name}
-
- ${status === 'ok' ? duration + 'ms' : ''}
-
-
+
+
+
+
+ ${name}
+
+ |
+ |
+ ${status === 'ok' ? duration + 'ms' : '—'} |
+ |
+
diff --git a/src/public/parts/shells/debug_info/public/templates/connectivity_list.html b/src/public/parts/shells/debug_info/public/templates/connectivity_list.html
index ff0a55e8e6..38f2e3aa44 100644
--- a/src/public/parts/shells/debug_info/public/templates/connectivity_list.html
+++ b/src/public/parts/shells/debug_info/public/templates/connectivity_list.html
@@ -1,8 +1,13 @@
${checks.map(check => `
-
- ${check.name}
-
- ${check.status === 'ok' && check.duration != null ? check.duration + 'ms' : ''}
-
-
+
+
+
+
+ ${check.name}
+
+ |
+ |
+ ${check.status === 'ok' && check.duration != null ? check.duration + 'ms' : check.status === 'ok' && check.activeLinks != null ? `` : '—'} |
+ |
+
`).join('')}
diff --git a/src/public/parts/shells/debug_info/public/templates/system_info_table.html b/src/public/parts/shells/debug_info/public/templates/system_info_table.html
index c56986c83a..3ec50da2e2 100644
--- a/src/public/parts/shells/debug_info/public/templates/system_info_table.html
+++ b/src/public/parts/shells/debug_info/public/templates/system_info_table.html
@@ -1,6 +1,11 @@
${rows.map(row => `
- | ${row.key} |
- ${row.val} |
+
+
+
+ ${row.key}
+
+ |
+ ${row.val} |
`).join('')}
diff --git a/src/public/parts/shells/debug_info/test/frontend/smoke.spec.mjs b/src/public/parts/shells/debug_info/test/frontend/smoke.spec.mjs
index 6092e51a7f..5bd8d6c512 100644
--- a/src/public/parts/shells/debug_info/test/frontend/smoke.spec.mjs
+++ b/src/public/parts/shells/debug_info/test/frontend/smoke.spec.mjs
@@ -5,13 +5,21 @@ import { test, expect } from './fixtures.mjs'
test.describe('debug_info shell smoke', () => {
test('debug info page boots with system table', async ({ page, baseUrl }) => {
+ await page.route('**/api/parts/shells:debug_info/system_info', route => route.fulfill({ json: {
+ os: { platform: 'test', release: '1', arch: 'x64' },
+ cpu: { model: 'test', cores: 1, speed: 1 },
+ memory: { total: 1, free: 0 },
+ connectivity: [{ id: 'fount-network', name: 'fount Network', status: 'ok', activeLinks: 2 }],
+ } }))
await page.goto(`${baseUrl}/parts/shells:debug_info/`, { waitUntil: 'domcontentloaded' })
await expect(page.locator('main')).toBeVisible({ timeout: 30_000 })
await expect(page.locator('#copy-button')).toBeVisible()
await expect(page.locator('h1[data-i18n="debug_info.heading"]')).toBeVisible()
+ await expect(page.locator('.page-status')).toHaveCount(0)
const systemTable = page.locator('#system-info-table')
await expect(systemTable).toBeVisible()
await expect(systemTable.locator('tr').first()).toBeVisible({ timeout: 30_000 })
+ await expect(page.locator('[data-i18n="debug_info.linksCount"][data-count="2"]')).toBeVisible()
})
test('update button surfaces the auto-update-disabled notice instead of staying silently disabled', async ({ page, baseUrl }) => {