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 }) => {