Compare commits

..
Author SHA1 Message Date
bincxz 118c015bd5 refactor(scripts): remove stacked library view 2026-10-18 00:19:11 +08:00
60 changed files with 513 additions and 4607 deletions

No files matched your search

+3 -8
View File
@@ -24,7 +24,7 @@ export const enMonitorMessages = {
"monitor.stopAll": "Stop all",
"monitor.list": "List",
"monitor.charts": "Charts",
"monitor.search": "Search hosts, groups, tags or notes",
"monitor.search": "Search name, address or group",
"monitor.interval": "Update interval",
"monitor.range": "Trend range",
"monitor.seconds": "Every {count}s",
@@ -34,13 +34,6 @@ export const enMonitorMessages = {
"monitor.sort.name": "Name",
"monitor.selectedCount": "Selected hosts: {count}",
"monitor.noMatches": "No matching hosts",
"monitor.latency": "Latency",
"monitor.responseLatency": "Response latency (includes remote processing)",
"monitor.tags": "Tags",
"monitor.notes": "Notes",
"monitor.visibleColumns": "Visible columns",
"monitor.terminal": "Terminal",
"monitor.resizeColumn": "Resize {column} column",
"monitor.host": "Host",
"monitor.hosts": "Hosts",
"monitor.status": "Status",
@@ -60,6 +53,8 @@ export const enMonitorMessages = {
"monitor.memoryTrend": "Memory trend",
"monitor.downloadTrend": "Download trend",
"monitor.uploadTrend": "Upload trend",
"monitor.memoryOnlyHint":
"Readings stay in memory for up to 30 minutes and disappear when the app closes. Gaps mean no reading. Shared connections use the fastest active update interval.",
"monitor.name": "Panel name",
"monitor.description": "Description (optional)",
"monitor.chooseHosts": "Choose hosts",
-65
View File
@@ -942,69 +942,4 @@ export const enTerminalMessages: Messages = {
'sftp.editor.saveAndClose': 'Save and close',
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
'terminal.browser.newTab': 'New tab',
'terminal.browser.address': 'Web address',
'terminal.browser.back': 'Back',
'terminal.browser.forward': 'Forward',
'terminal.browser.reload': 'Reload',
'terminal.browser.stop': 'Stop',
'terminal.browser.bookmarks': 'Apps & bookmarks',
'terminal.browser.downloads': 'Downloads',
'terminal.browser.empty': 'Save an app or enter an address to get started.',
'terminal.browser.connecting': 'Connecting…',
'terminal.browser.disconnected': 'Connection unavailable',
'terminal.browser.retry': 'Reconnect',
'terminal.browser.addBookmark': 'Add app',
'terminal.browser.editBookmark': 'Edit app',
'terminal.browser.bookmarkTitle': 'Title',
'terminal.browser.icon': 'Icon',
'terminal.browser.website': 'Website',
'terminal.browser.text': 'Text',
'terminal.browser.image': 'Image',
'terminal.browser.chooseImage': 'Choose image',
'terminal.browser.color': 'Color',
'terminal.browser.scope': 'Show on',
'terminal.browser.hostScope': 'This host',
'terminal.browser.allScope': 'All hosts',
'terminal.browser.changeIcon': 'Change icon',
'terminal.browser.resetIcon': 'Use website icon',
'terminal.browser.downloadEmpty': 'No downloads yet',
'terminal.browser.openFolder': 'Open downloads folder',
'terminal.browser.clearDownloads': 'Clear finished downloads',
'terminal.browser.cancelDownload': 'Cancel download',
'terminal.browser.reveal': 'Show in folder',
'terminal.browser.openFile': 'Open file',
'terminal.browser.removeDownload': 'Remove from list',
'terminal.browser.waiting': 'Preparing download…',
'terminal.browser.progressing': 'Downloading',
'terminal.browser.completed': 'Completed',
'terminal.browser.cancelled': 'Cancelled',
'terminal.browser.interrupted': 'Interrupted',
'terminal.browser.missing': 'File moved or deleted',
'terminal.browser.closeTab': 'Close tab',
'terminal.browser.more': 'More actions',
'terminal.browser.websiteIconHint': 'The website icon appears automatically after you visit it.',
'terminal.browser.iconText': 'Icon text',
'terminal.browser.iconTextHint': 'Use a letter, short name or emoji.',
'terminal.browser.selectColor': 'Use {color}',
'terminal.browser.customColor': 'Custom color',
'terminal.browser.iconPreview': 'Icon preview',
'terminal.browser.savedImage': 'Saved image',
'terminal.browser.processingImage': 'Preparing image…',
'terminal.browser.replaceImage': 'Replace image',
'terminal.browser.removeImage': 'Remove image',
'terminal.browser.uploadImage': 'Choose an image',
'terminal.browser.dropImage': 'or drag and drop it here',
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF or ICO · up to 5 MB. Resized automatically.',
'terminal.browser.iconError.file-type': 'Choose a PNG, JPG, WebP, GIF or ICO image.',
'terminal.browser.iconError.file-size': 'This image is too large. Choose a file under 5 MB.',
'terminal.browser.iconError.decode': 'This image could not be read. Try another file.',
'terminal.browser.bookmarkPreviewHint': 'Shown on your browser start page',
'terminal.browser.untitledBookmark': 'Your app',
'terminal.browser.bookmarkTitlePlaceholder': 'e.g. Team docs',
'terminal.browser.websiteAddress': 'Website address',
'terminal.browser.hostScopeHint': 'Show only in this host’s browser.',
'terminal.browser.allScopeHint': 'Show in every host’s browser.',
'terminal.browser.downloadLocation': 'Saved to your Downloads folder',
'terminal.browser.saveAs': 'Save as…',
};
+3 -8
View File
@@ -24,7 +24,7 @@ export const esMonitorMessages = {
"monitor.stopAll": "Detener todo",
"monitor.list": "Lista",
"monitor.charts": "Gráficos",
"monitor.search": "Buscar hosts, grupos, etiquetas o notas",
"monitor.search": "Buscar nombre, dirección o grupo",
"monitor.interval": "Intervalo de actualización",
"monitor.range": "Rango de tendencias",
"monitor.seconds": "Cada {count} s",
@@ -34,13 +34,6 @@ export const esMonitorMessages = {
"monitor.sort.name": "Nombre",
"monitor.selectedCount": "Equipos seleccionados: {count}",
"monitor.noMatches": "No hay equipos coincidentes",
"monitor.latency": "Latencia",
"monitor.responseLatency": "Latencia de respuesta (incluye procesamiento remoto)",
"monitor.tags": "Etiquetas",
"monitor.notes": "Notas",
"monitor.visibleColumns": "Columnas visibles",
"monitor.terminal": "Terminal",
"monitor.resizeColumn": "Ajustar la columna {column}",
"monitor.host": "Equipo",
"monitor.hosts": "Equipos",
"monitor.status": "Estado",
@@ -60,6 +53,8 @@ export const esMonitorMessages = {
"monitor.memoryTrend": "Tendencia de memoria",
"monitor.downloadTrend": "Tendencia de descarga",
"monitor.uploadTrend": "Tendencia de subida",
"monitor.memoryOnlyHint":
"Las lecturas se conservan en memoria hasta 30 minutos y desaparecen al cerrar. Los huecos indican datos ausentes. Las conexiones compartidas usan el intervalo activo más rápido.",
"monitor.name": "Nombre del panel",
"monitor.description": "Descripción (opcional)",
"monitor.chooseHosts": "Elegir equipos",
-65
View File
@@ -904,69 +904,4 @@ export const esTerminalMessages: Messages = {
'sftp.editor.saveAndClose': 'Guardar y cerrar',
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
'terminal.browser.newTab': 'Nueva pestaña',
'terminal.browser.address': 'Dirección web',
'terminal.browser.back': 'Atrás',
'terminal.browser.forward': 'Adelante',
'terminal.browser.reload': 'Recargar',
'terminal.browser.stop': 'Detener',
'terminal.browser.bookmarks': 'Aplicaciones y marcadores',
'terminal.browser.downloads': 'Descargas',
'terminal.browser.empty': 'Añade una aplicación o introduce una dirección.',
'terminal.browser.connecting': 'Conectando…',
'terminal.browser.disconnected': 'Conexión no disponible',
'terminal.browser.retry': 'Reconectar',
'terminal.browser.addBookmark': 'Añadir aplicación',
'terminal.browser.editBookmark': 'Editar aplicación',
'terminal.browser.bookmarkTitle': 'Título',
'terminal.browser.icon': 'Icono',
'terminal.browser.website': 'Sitio web',
'terminal.browser.text': 'Texto',
'terminal.browser.image': 'Imagen',
'terminal.browser.chooseImage': 'Elegir imagen',
'terminal.browser.color': 'Color',
'terminal.browser.scope': 'Mostrar en',
'terminal.browser.hostScope': 'Este equipo',
'terminal.browser.allScope': 'Todos los equipos',
'terminal.browser.changeIcon': 'Cambiar icono',
'terminal.browser.resetIcon': 'Usar icono del sitio',
'terminal.browser.downloadEmpty': 'No hay descargas',
'terminal.browser.openFolder': 'Abrir carpeta de descargas',
'terminal.browser.clearDownloads': 'Borrar descargas finalizadas',
'terminal.browser.cancelDownload': 'Cancelar descarga',
'terminal.browser.reveal': 'Mostrar en carpeta',
'terminal.browser.openFile': 'Abrir archivo',
'terminal.browser.removeDownload': 'Quitar de la lista',
'terminal.browser.waiting': 'Preparando descarga…',
'terminal.browser.progressing': 'Descargando',
'terminal.browser.completed': 'Completada',
'terminal.browser.cancelled': 'Cancelada',
'terminal.browser.interrupted': 'Interrumpida',
'terminal.browser.missing': 'Archivo movido o eliminado',
'terminal.browser.closeTab': 'Cerrar pestaña',
'terminal.browser.more': 'Más acciones',
'terminal.browser.websiteIconHint': 'El icono se obtiene automáticamente al visitar el sitio.',
'terminal.browser.iconText': 'Texto del icono',
'terminal.browser.iconTextHint': 'Usa una letra, abreviatura o emoji.',
'terminal.browser.selectColor': 'Usar {color}',
'terminal.browser.customColor': 'Color personalizado',
'terminal.browser.iconPreview': 'Vista previa del icono',
'terminal.browser.savedImage': 'Imagen guardada',
'terminal.browser.processingImage': 'Preparando imagen…',
'terminal.browser.replaceImage': 'Cambiar imagen',
'terminal.browser.removeImage': 'Quitar imagen',
'terminal.browser.uploadImage': 'Elegir imagen',
'terminal.browser.dropImage': 'o arrástrala aquí',
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF o ICO · hasta 5 MB. Se ajusta automáticamente.',
'terminal.browser.iconError.file-type': 'Elige una imagen PNG, JPG, WebP, GIF o ICO.',
'terminal.browser.iconError.file-size': 'La imagen es demasiado grande. Elige un archivo de hasta 5 MB.',
'terminal.browser.iconError.decode': 'No se pudo leer la imagen. Prueba con otro archivo.',
'terminal.browser.bookmarkPreviewHint': 'Aparece en la página de inicio del navegador',
'terminal.browser.untitledBookmark': 'Tu aplicación',
'terminal.browser.bookmarkTitlePlaceholder': 'p. ej., Documentos del equipo',
'terminal.browser.websiteAddress': 'Dirección del sitio',
'terminal.browser.hostScopeHint': 'Mostrar solo en el navegador de este host.',
'terminal.browser.allScopeHint': 'Mostrar en el navegador de todos los hosts.',
'terminal.browser.downloadLocation': 'Guardado en la carpeta Descargas',
'terminal.browser.saveAs': 'Guardar como…',
};
+3 -8
View File
@@ -23,7 +23,7 @@ export const ruMonitorMessages = {
"monitor.stopAll": "Остановить всё",
"monitor.list": "Список",
"monitor.charts": "Графики",
"monitor.search": "Поиск узлов, групп, тегов и заметок",
"monitor.search": "Имя, адрес или группа",
"monitor.interval": "Интервал обновления",
"monitor.range": "Период графика",
"monitor.seconds": "Каждые {count} с",
@@ -33,13 +33,6 @@ export const ruMonitorMessages = {
"monitor.sort.name": "Имя",
"monitor.selectedCount": "Выбрано серверов: {count}",
"monitor.noMatches": "Нет подходящих серверов",
"monitor.latency": "Задержка",
"monitor.responseLatency": "Задержка ответа (включая обработку на сервере)",
"monitor.tags": "Теги",
"monitor.notes": "Заметки",
"monitor.visibleColumns": "Видимые столбцы",
"monitor.terminal": "Терминал",
"monitor.resizeColumn": "Изменить ширину столбца «{column}»",
"monitor.host": "Сервер",
"monitor.hosts": "Серверы",
"monitor.status": "Состояние",
@@ -59,6 +52,8 @@ export const ruMonitorMessages = {
"monitor.memoryTrend": "График памяти",
"monitor.downloadTrend": "График приёма",
"monitor.uploadTrend": "График передачи",
"monitor.memoryOnlyHint":
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
"monitor.name": "Название панели",
"monitor.description": "Описание (необязательно)",
"monitor.chooseHosts": "Выбрать серверы",
-65
View File
@@ -949,69 +949,4 @@ export const ruTerminalMessages: Messages = {
'sftp.editor.saveAndClose': 'Сохранить и закрыть',
'sftp.editor.quitBlockedByDirty': 'Есть несохранённые редакторы — перед выходом сохраните изменения или отбросьте их',
'terminal.browser.newTab': 'Новая вкладка',
'terminal.browser.address': 'Веб-адрес',
'terminal.browser.back': 'Назад',
'terminal.browser.forward': 'Вперёд',
'terminal.browser.reload': 'Обновить',
'terminal.browser.stop': 'Остановить',
'terminal.browser.bookmarks': 'Приложения и закладки',
'terminal.browser.downloads': 'Загрузки',
'terminal.browser.empty': 'Добавьте приложение или введите адрес.',
'terminal.browser.connecting': 'Подключение…',
'terminal.browser.disconnected': 'Соединение недоступно',
'terminal.browser.retry': 'Подключиться снова',
'terminal.browser.addBookmark': 'Добавить приложение',
'terminal.browser.editBookmark': 'Изменить приложение',
'terminal.browser.bookmarkTitle': 'Название',
'terminal.browser.icon': 'Значок',
'terminal.browser.website': 'Значок сайта',
'terminal.browser.text': 'Текст',
'terminal.browser.image': 'Изображение',
'terminal.browser.chooseImage': 'Выбрать изображение',
'terminal.browser.color': 'Цвет',
'terminal.browser.scope': 'Показывать на',
'terminal.browser.hostScope': 'Этот хост',
'terminal.browser.allScope': 'Все хосты',
'terminal.browser.changeIcon': 'Изменить значок',
'terminal.browser.resetIcon': 'Использовать значок сайта',
'terminal.browser.downloadEmpty': 'Загрузок пока нет',
'terminal.browser.openFolder': 'Открыть папку загрузок',
'terminal.browser.clearDownloads': 'Очистить завершённые загрузки',
'terminal.browser.cancelDownload': 'Отменить загрузку',
'terminal.browser.reveal': 'Показать в папке',
'terminal.browser.openFile': 'Открыть файл',
'terminal.browser.removeDownload': 'Удалить из списка',
'terminal.browser.waiting': 'Подготовка загрузки…',
'terminal.browser.progressing': 'Загрузка',
'terminal.browser.completed': 'Завершена',
'terminal.browser.cancelled': 'Отменена',
'terminal.browser.interrupted': 'Прервана',
'terminal.browser.missing': 'Файл перемещён или удалён',
'terminal.browser.closeTab': 'Закрыть вкладку',
'terminal.browser.more': 'Другие действия',
'terminal.browser.websiteIconHint': 'Значок загрузится автоматически после посещения сайта.',
'terminal.browser.iconText': 'Текст значка',
'terminal.browser.iconTextHint': 'Буква, сокращение или эмодзи.',
'terminal.browser.selectColor': 'Выбрать {color}',
'terminal.browser.customColor': 'Свой цвет',
'terminal.browser.iconPreview': 'Предпросмотр значка',
'terminal.browser.savedImage': 'Сохранённое изображение',
'terminal.browser.processingImage': 'Обработка изображения…',
'terminal.browser.replaceImage': 'Заменить',
'terminal.browser.removeImage': 'Удалить изображение',
'terminal.browser.uploadImage': 'Выбрать изображение',
'terminal.browser.dropImage': 'или перетащить его сюда',
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF или ICO · до 5 МБ. Размер подбирается автоматически.',
'terminal.browser.iconError.file-type': 'Выберите PNG, JPG, WebP, GIF или ICO.',
'terminal.browser.iconError.file-size': 'Изображение слишком большое. Выберите файл до 5 МБ.',
'terminal.browser.iconError.decode': 'Не удалось прочитать изображение. Выберите другой файл.',
'terminal.browser.bookmarkPreviewHint': 'Отображается на начальной странице браузера',
'terminal.browser.untitledBookmark': 'Ваше приложение',
'terminal.browser.bookmarkTitlePlaceholder': 'Например, Документы команды',
'terminal.browser.websiteAddress': 'Адрес сайта',
'terminal.browser.hostScopeHint': 'Только в браузере текущего хоста.',
'terminal.browser.allScopeHint': 'В браузерах всех хостов.',
'terminal.browser.downloadLocation': 'Файлы сохраняются в папку «Загрузки»',
'terminal.browser.saveAs': 'Сохранить как…',
};
+3 -8
View File
@@ -22,7 +22,7 @@ export const zhCNMonitorMessages = {
"monitor.stopAll": "全部停止",
"monitor.list": "列表",
"monitor.charts": "图表",
"monitor.search": "搜索主机、分组、标签或备注",
"monitor.search": "搜索名称、地址或分组",
"monitor.interval": "更新频率",
"monitor.range": "趋势范围",
"monitor.seconds": "每 {count} 秒",
@@ -32,13 +32,6 @@ export const zhCNMonitorMessages = {
"monitor.sort.name": "名称",
"monitor.selectedCount": "已选 {count} 台主机",
"monitor.noMatches": "没有符合条件的主机",
"monitor.latency": "延迟",
"monitor.responseLatency": "响应延迟(含远端处理)",
"monitor.tags": "标签",
"monitor.notes": "备注",
"monitor.visibleColumns": "显示的列",
"monitor.terminal": "终端",
"monitor.resizeColumn": "调整「{column}」列宽",
"monitor.host": "主机",
"monitor.hosts": "主机",
"monitor.status": "状态",
@@ -58,6 +51,8 @@ export const zhCNMonitorMessages = {
"monitor.memoryTrend": "内存趋势",
"monitor.downloadTrend": "下载趋势",
"monitor.uploadTrend": "上传趋势",
"monitor.memoryOnlyHint":
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
"monitor.name": "面板名称",
"monitor.description": "说明(可选)",
"monitor.chooseHosts": "选择主机",
@@ -1028,69 +1028,4 @@ export const zhCNTerminalMessages: Messages = {
'sftp.editor.saveAndClose': '保存并关闭',
'sftp.editor.quitBlockedByDirty': '存在未保存的编辑器,请先处理后再退出',
'terminal.browser.newTab': '新标签页',
'terminal.browser.address': '输入网址',
'terminal.browser.back': '后退',
'terminal.browser.forward': '前进',
'terminal.browser.reload': '刷新',
'terminal.browser.stop': '停止',
'terminal.browser.bookmarks': '应用与书签',
'terminal.browser.downloads': '下载',
'terminal.browser.empty': '添加应用或输入网址即可开始浏览。',
'terminal.browser.connecting': '正在连接…',
'terminal.browser.disconnected': '连接不可用',
'terminal.browser.retry': '重新连接',
'terminal.browser.addBookmark': '添加应用',
'terminal.browser.editBookmark': '编辑应用',
'terminal.browser.bookmarkTitle': '名称',
'terminal.browser.icon': '图标',
'terminal.browser.website': '网站图标',
'terminal.browser.text': '文字',
'terminal.browser.image': '图片',
'terminal.browser.chooseImage': '选择图片',
'terminal.browser.color': '颜色',
'terminal.browser.scope': '显示范围',
'terminal.browser.hostScope': '此主机',
'terminal.browser.allScope': '所有主机',
'terminal.browser.changeIcon': '更改图标',
'terminal.browser.resetIcon': '使用网站图标',
'terminal.browser.downloadEmpty': '暂无下载',
'terminal.browser.openFolder': '打开下载文件夹',
'terminal.browser.clearDownloads': '清除已结束的下载',
'terminal.browser.cancelDownload': '取消下载',
'terminal.browser.reveal': '在文件夹中显示',
'terminal.browser.openFile': '打开文件',
'terminal.browser.removeDownload': '从列表移除',
'terminal.browser.waiting': '正在准备下载…',
'terminal.browser.progressing': '正在下载',
'terminal.browser.completed': '已完成',
'terminal.browser.cancelled': '已取消',
'terminal.browser.interrupted': '已中断',
'terminal.browser.missing': '文件已移动或删除',
'terminal.browser.closeTab': '关闭标签页',
'terminal.browser.more': '更多操作',
'terminal.browser.websiteIconHint': '访问网站后,自动获取并显示网站图标。',
'terminal.browser.iconText': '图标文字',
'terminal.browser.iconTextHint': '可以使用字母、简称或表情。',
'terminal.browser.selectColor': '使用 {color}',
'terminal.browser.customColor': '自定义颜色',
'terminal.browser.iconPreview': '图标预览',
'terminal.browser.savedImage': '已保存的图片',
'terminal.browser.processingImage': '正在处理图片…',
'terminal.browser.replaceImage': '替换图片',
'terminal.browser.removeImage': '移除图片',
'terminal.browser.uploadImage': '选择图片',
'terminal.browser.dropImage': '也可以将图片拖到这里',
'terminal.browser.imageHint': '支持 PNG、JPG、WebP、GIF、ICO,最大 5 MB,自动适配图标大小。',
'terminal.browser.iconError.file-type': '请选择 PNG、JPG、WebP、GIF 或 ICO 图片。',
'terminal.browser.iconError.file-size': '图片太大,请选择不超过 5 MB 的文件。',
'terminal.browser.iconError.decode': '无法读取这张图片,请换一个文件。',
'terminal.browser.bookmarkPreviewHint': '显示在浏览器开始页',
'terminal.browser.untitledBookmark': '应用预览',
'terminal.browser.bookmarkTitlePlaceholder': '例如,团队文档',
'terminal.browser.websiteAddress': '网址',
'terminal.browser.hostScopeHint': '仅在当前主机的浏览器中显示。',
'terminal.browser.allScopeHint': '在每台主机的浏览器中显示。',
'terminal.browser.downloadLocation': '文件保存在系统“下载”文件夹',
'terminal.browser.saveAs': '另存为…',
};
+3 -8
View File
@@ -22,7 +22,7 @@ export const zhTWMonitorMessages = {
"monitor.stopAll": "全部停止",
"monitor.list": "列表",
"monitor.charts": "圖表",
"monitor.search": "搜尋主機、群組、標籤或備註",
"monitor.search": "搜尋名稱、位址或群組",
"monitor.interval": "更新頻率",
"monitor.range": "趨勢範圍",
"monitor.seconds": "每 {count} 秒",
@@ -32,13 +32,6 @@ export const zhTWMonitorMessages = {
"monitor.sort.name": "名稱",
"monitor.selectedCount": "已選 {count} 台主機",
"monitor.noMatches": "沒有符合條件的主機",
"monitor.latency": "延遲",
"monitor.responseLatency": "回應延遲(含遠端處理)",
"monitor.tags": "標籤",
"monitor.notes": "備註",
"monitor.visibleColumns": "顯示的欄位",
"monitor.terminal": "終端",
"monitor.resizeColumn": "調整「{column}」欄寬",
"monitor.host": "主機",
"monitor.hosts": "主機",
"monitor.status": "狀態",
@@ -58,6 +51,8 @@ export const zhTWMonitorMessages = {
"monitor.memoryTrend": "記憶體趨勢",
"monitor.downloadTrend": "下載趨勢",
"monitor.uploadTrend": "上傳趨勢",
"monitor.memoryOnlyHint":
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
"monitor.name": "面板名稱",
"monitor.description": "說明(選填)",
"monitor.chooseHosts": "選擇主機",
@@ -1028,69 +1028,4 @@ export const zhTWTerminalMessages: Messages = {
'sftp.editor.saveAndClose': '儲存並關閉',
'sftp.editor.quitBlockedByDirty': '存在未儲存的編輯器,請先處理後再離開',
'terminal.browser.newTab': '新增分頁',
'terminal.browser.address': '輸入網址',
'terminal.browser.back': '上一頁',
'terminal.browser.forward': '下一頁',
'terminal.browser.reload': '重新整理',
'terminal.browser.stop': '停止',
'terminal.browser.bookmarks': '應用程式與書籤',
'terminal.browser.downloads': '下載',
'terminal.browser.empty': '新增應用程式或輸入網址即可開始瀏覽。',
'terminal.browser.connecting': '正在連線…',
'terminal.browser.disconnected': '連線無法使用',
'terminal.browser.retry': '重新連線',
'terminal.browser.addBookmark': '新增應用程式',
'terminal.browser.editBookmark': '編輯應用程式',
'terminal.browser.bookmarkTitle': '名稱',
'terminal.browser.icon': '圖示',
'terminal.browser.website': '網站圖示',
'terminal.browser.text': '文字',
'terminal.browser.image': '圖片',
'terminal.browser.chooseImage': '選擇圖片',
'terminal.browser.color': '顏色',
'terminal.browser.scope': '顯示範圍',
'terminal.browser.hostScope': '此主機',
'terminal.browser.allScope': '所有主機',
'terminal.browser.changeIcon': '變更圖示',
'terminal.browser.resetIcon': '使用網站圖示',
'terminal.browser.downloadEmpty': '尚無下載',
'terminal.browser.openFolder': '開啟下載資料夾',
'terminal.browser.clearDownloads': '清除已結束的下載',
'terminal.browser.cancelDownload': '取消下載',
'terminal.browser.reveal': '在資料夾中顯示',
'terminal.browser.openFile': '開啟檔案',
'terminal.browser.removeDownload': '從清單移除',
'terminal.browser.waiting': '正在準備下載…',
'terminal.browser.progressing': '正在下載',
'terminal.browser.completed': '已完成',
'terminal.browser.cancelled': '已取消',
'terminal.browser.interrupted': '已中斷',
'terminal.browser.missing': '檔案已移動或刪除',
'terminal.browser.closeTab': '關閉分頁',
'terminal.browser.more': '更多操作',
'terminal.browser.websiteIconHint': '造訪網站後,自動取得並顯示網站圖示。',
'terminal.browser.iconText': '圖示文字',
'terminal.browser.iconTextHint': '可以使用字母、簡稱或表情符號。',
'terminal.browser.selectColor': '使用 {color}',
'terminal.browser.customColor': '自訂顏色',
'terminal.browser.iconPreview': '圖示預覽',
'terminal.browser.savedImage': '已儲存的圖片',
'terminal.browser.processingImage': '正在處理圖片…',
'terminal.browser.replaceImage': '替換圖片',
'terminal.browser.removeImage': '移除圖片',
'terminal.browser.uploadImage': '選擇圖片',
'terminal.browser.dropImage': '也可以將圖片拖到這裡',
'terminal.browser.imageHint': '支援 PNG、JPG、WebP、GIF、ICO,最大 5 MB,自動調整圖示大小。',
'terminal.browser.iconError.file-type': '請選擇 PNG、JPG、WebP、GIF 或 ICO 圖片。',
'terminal.browser.iconError.file-size': '圖片太大,請選擇不超過 5 MB 的檔案。',
'terminal.browser.iconError.decode': '無法讀取這張圖片,請更換檔案。',
'terminal.browser.bookmarkPreviewHint': '顯示在瀏覽器起始頁',
'terminal.browser.untitledBookmark': '應用程式預覽',
'terminal.browser.bookmarkTitlePlaceholder': '例如,團隊文件',
'terminal.browser.websiteAddress': '網址',
'terminal.browser.hostScopeHint': '僅在目前主機的瀏覽器中顯示。',
'terminal.browser.allScopeHint': '在每台主機的瀏覽器中顯示。',
'terminal.browser.downloadLocation': '檔案儲存在系統「下載項目」資料夾',
'terminal.browser.saveAs': '另存新檔…',
};
@@ -1,49 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
publishMonitorSnapshot,
getMonitorHistoryCursor,
} from "./monitorRuntimeStore";
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
test("renderer cursor acknowledges GPU publication separately from CPU sample and history", () => {
const snapshot: MonitorSnapshot = {
historyMode: "replace",
runtimeId: "gpu-cursor-test",
epoch: 1,
takenAt: 1000,
running: ["panel"],
count: 1,
hostCount: 1,
activeSessions: [],
startedAt: { panel: 0 },
hosts: {
host: {
historyId: "history",
status: "live",
at: 1000,
statsAt: 1001,
points: [
{ at: 1000, cpu: 10, memory: 20, disk: null, rx: null, tx: null },
],
},
},
};
publishMonitorSnapshot(snapshot);
assert.deepEqual(getMonitorHistoryCursor().after.host, {
at: 1000,
statsAt: 1001,
historyId: "history",
});
publishMonitorSnapshot({
...snapshot,
historyMode: "append",
takenAt: 2000,
hosts: { host: { ...snapshot.hosts.host, statsAt: 2001, points: [] } },
});
assert.deepEqual(getMonitorHistoryCursor().after.host, {
at: 1000,
statsAt: 2001,
historyId: "history",
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ export function getMonitorHistoryCursor(): MonitorHistoryCursor {
return {
runtimeId: snapshot.runtimeId,
epoch: snapshot.epoch,
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.statsAt ?? host.at, historyId: host.historyId }])),
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.at, historyId: host.historyId }])),
};
}
export function publishMonitorUnavailable() {
@@ -1,60 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create } from 'react-test-renderer';
import { installDomEnvironment } from '../../components/test-support/renderReactDom';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { HostBrowserPanelEvent, HostBrowserState } from '../../domain/hostBrowser';
import { useHostBrowserControls } from './useHostBrowserControls';
const state = (label: string): HostBrowserState => ({ hostId: label, label, route: 'connected', error: null, activeTabId: null, tabs: [] });
test('browser events stay scoped and a late initial read cannot overwrite a newer page', async (t) => {
const env = installDomEnvironment();
let resolveInitial: (value: HostBrowserState) => void = () => assert.fail('initial read missing');
let emit: (event: HostBrowserPanelEvent) => void = () => {};
let unsubscribed = false;
t.mock.method(netcattyBridge, 'require', () => ({
getHostBrowserPanelState: () => new Promise<HostBrowserState>((resolve) => { resolveInitial = resolve; }),
commandHostBrowserPanel: async () => state('command'),
listHostBrowserPanelBookmarks: async () => [],
listHostBrowserPanelDownloads: async () => [],
onHostBrowserPanelEvent: (callback: typeof emit) => { emit = callback; return () => { unsubscribed = true; }; },
}));
let controls: ReturnType<typeof useHostBrowserControls>;
function Panel() { controls = useHostBrowserControls('browser-a', true); return null; }
let renderer: ReturnType<typeof create>;
t.after(async () => { if (renderer) await act(async () => renderer.unmount()); env.cleanup(); });
await act(async () => { renderer = create(React.createElement(Panel)); });
await act(async () => emit({ browserId: 'browser-b', type: 'state', state: state('wrong host') }));
assert.equal(controls.state, null);
await act(async () => emit({ browserId: 'browser-a', type: 'state', state: state('new page') }));
await act(async () => resolveInitial(state('old page')));
assert.equal(controls.state?.label, 'new page');
await act(async () => renderer.unmount());
assert.equal(unsubscribed, true);
});
test('switching browser contexts invalidates an outstanding navigation response', async (t) => {
const env = installDomEnvironment();
let finish: (value: HostBrowserState) => void = () => assert.fail('command missing');
const commands: string[] = [];
t.mock.method(netcattyBridge, 'require', () => ({
getHostBrowserPanelState: async (id: string) => state(id),
commandHostBrowserPanel: (id: string) => { commands.push(id); return new Promise<HostBrowserState>((resolve) => { finish = resolve; }); },
listHostBrowserPanelBookmarks: async () => [], listHostBrowserPanelDownloads: async () => [],
onHostBrowserPanelEvent: () => () => {},
}));
let controls: ReturnType<typeof useHostBrowserControls>;
function Panel({ id }: { id: string }) { controls = useHostBrowserControls(id, true); return null; }
let renderer: ReturnType<typeof create>;
t.after(async () => { if (renderer) await act(async () => renderer.unmount()); env.cleanup(); });
await act(async () => { renderer = create(React.createElement(Panel, { id: 'browser-a' })); });
let pending: Promise<boolean>;
await act(async () => { pending = controls.command({ type: 'navigate', url: 'http://localhost/' }); });
await act(async () => renderer.update(React.createElement(Panel, { id: 'browser-b' })));
await act(async () => finish(state('late browser-a')));
assert.equal(await pending, false);
assert.equal(controls.state?.label, 'browser-b');
assert.deepEqual(commands, ['browser-a']);
});
@@ -1,95 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { BrowserBookmark } from '../../domain/browserBookmark';
import type { HostBrowserBookmark, HostBrowserCommand, HostBrowserDownload, HostBrowserState } from '../../domain/hostBrowser';
import { hostBrowserClient, readHostBrowserIcon } from '../../infrastructure/services/hostBrowserService';
export function useHostBrowserControls(browserId: string, ready: boolean) {
const [state, setState] = useState<HostBrowserState | null>(null);
const [bookmarks, setBookmarks] = useState<HostBrowserBookmark[]>([]);
const [downloads, setDownloads] = useState<HostBrowserDownload[]>([]);
const [error, setError] = useState<string | null>(null);
const [focusAddress, setFocusAddress] = useState(0);
const [downloadStarted, setDownloadStarted] = useState(0);
const generation = useRef(0);
const client = useRef<ReturnType<typeof hostBrowserClient> | null>(null);
const report = useCallback((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)), []);
const command = useCallback(async (input: HostBrowserCommand) => {
const current = client.current;
const revision = generation.current;
if (!current) return false;
setError(null);
try {
const next = await current.command(input);
if (revision !== generation.current) return false;
setState(next);
if (input.type === 'new-tab' || (input.type === 'close-tab' && !next.tabs.find((tab) => tab.id === next.activeTabId)?.url)) setFocusAddress((value) => value + 1);
return true;
} catch (reason) { if (revision === generation.current) report(reason); return false; }
}, [report]);
useEffect(() => {
const revision = ++generation.current;
setState(null); setBookmarks([]); setDownloads([]); setError(null); setDownloadStarted(0);
if (!ready) return;
let current: ReturnType<typeof hostBrowserClient>;
try { current = hostBrowserClient(browserId); } catch (reason) { report(reason); return; }
client.current = current;
let stateRevision = 0;
let bookmarkRevision = 0;
let downloadRevision = 0;
const active = () => revision === generation.current;
const refreshBookmarks = async () => {
const request = ++bookmarkRevision;
try { const items = await current.bookmarks(); if (active() && request === bookmarkRevision) setBookmarks(items); }
catch (reason) { if (active()) report(reason); }
};
const off = current.subscribe((event) => {
if (!active()) return;
if (event.type === 'state') { stateRevision++; setState(event.state); }
if (event.type === 'bookmarks') void refreshBookmarks();
if (event.type === 'downloads') {
downloadRevision++; setDownloads(event.downloads);
if (event.startedId) setDownloadStarted((value) => value + 1);
}
if (event.type === 'shortcut') {
if (event.shortcut.type === 'focus-address') setFocusAddress((value) => value + 1);
else void command(event.shortcut.type === 'new-tab' ? { type: 'new-tab' } : { type: event.shortcut.type, tabId: event.shortcut.tabId });
}
});
const requestState = stateRevision;
void current.state().then((next) => { if (active() && stateRevision === requestState) setState(next); }).catch((reason) => { if (active()) report(reason); });
void refreshBookmarks();
const requestDownloads = downloadRevision;
void current.downloads().then((items) => { if (active() && requestDownloads === downloadRevision) setDownloads(items); }).catch((reason) => { if (active()) report(reason); });
return () => { generation.current = revision + 1; client.current = null; off(); };
}, [browserId, ready, command, report]);
const mutateBookmark = useCallback(async (input: Partial<BrowserBookmark> | string) => {
const current = client.current;
const revision = generation.current;
if (!current) return false;
try {
if (typeof input === 'string') await current.removeBookmark(input);
else await current.saveBookmark(input);
const items = await current.bookmarks();
if (revision === generation.current) { setBookmarks(items); setError(null); }
return true;
} catch (reason) { if (revision === generation.current) report(reason); return false; }
}, [report]);
const downloadCommand = useCallback(async (type: Parameters<ReturnType<typeof hostBrowserClient>['downloadCommand']>[0]['type'], id?: string) => {
const current = client.current;
const revision = generation.current;
if (!current) return;
try {
await current.downloadCommand({ type, id });
const items = await current.downloads();
if (revision === generation.current) setDownloads(items);
} catch (reason) { if (revision === generation.current) report(reason); }
}, [report]);
const readIcon = useCallback((file: File) => readHostBrowserIcon(file), []);
return { state, bookmarks, downloads, error, command, mutateBookmark, downloadCommand, focusAddress, downloadStarted,
requestAddressFocus: () => setFocusAddress((value) => value + 1), readIcon, dismissError: () => setError(null) };
}
@@ -1,130 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create } from 'react-test-renderer';
import { installDomEnvironment } from '../../components/test-support/renderReactDom';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { TerminalSettings } from '../../domain/models';
import { useHostBrowserPanel } from './useHostBrowserPanel';
type Layout = Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0];
type Result = Awaited<ReturnType<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>>;
const layers = [{ image: 'data:image/png;base64,fixture', bounds: { x: 0, y: 0, width: 500, height: 600 } }];
async function fixture(t: test.TestContext) {
const env = installDomEnvironment();
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
const originals = new Map(['requestAnimationFrame', 'cancelAnimationFrame', 'getComputedStyle']
.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]));
Object.defineProperty(globalThis, 'requestAnimationFrame', { configurable: true,
value: (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; } });
Object.defineProperty(globalThis, 'cancelAnimationFrame', { configurable: true,
value: (id: number) => frames.delete(id) });
const style = env.document.createElement('div').style;
style.colorScheme = 'dark';
globalThis.getComputedStyle = () => style;
const layouts: Layout[] = [];
let respond: (layout: Layout) => Promise<Result> = async (layout) => layout.occluded ? { layers } : true;
t.mock.method(netcattyBridge, 'get', () => ({
setHostBrowserPanelLayout: async (layout: Layout) => { layouts.push(layout); return respond(layout); },
openHostBrowser: async () => {},
startPortForward: async () => assert.fail('a local browser needs no SSH transport'),
closeHostBrowserPanel: async () => {},
}));
let panel: ReturnType<typeof useHostBrowserPanel>;
function Panel({ visible = true, browserId = 'local:overlay-test' }) {
panel = useHostBrowserPanel({ browserId,
host: { id: 'local', protocol: 'local', label: 'Local terminal', hostname: '', username: '', tags: [], os: 'macos' },
hosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {} as TerminalSettings,
isVisible: visible });
return React.createElement('div', { ref: panel.elementRef });
}
const element = { isConnected: true, closest: () => null,
getBoundingClientRect: () => new env.window.DOMRect(200, 100, 500, 600) };
let renderer: ReturnType<typeof create>;
t.after(async () => {
if (renderer) await act(async () => renderer.unmount());
for (const [name, previous] of originals) {
if (previous) Object.defineProperty(globalThis, name, previous);
else Reflect.deleteProperty(globalThis, name);
}
env.cleanup();
});
await act(async () => { renderer = create(React.createElement(Panel), { createNodeMock: () => element }); });
const overlay = (html: string, x = 220, y = 110) => {
env.document.body.insertAdjacentHTML('beforeend', html);
const node = env.document.body.lastElementChild!;
for (const target of [node, ...node.children]) {
target.getBoundingClientRect = () => new env.window.DOMRect(x, y, 150, 40);
}
return node;
};
const tick = async () => {
await act(async () => {
const pending = [...frames.values()]; frames.clear();
for (const callback of pending) callback(0);
});
};
return { layouts, overlay, tick, panel: () => panel,
respond: (callback: typeof respond) => { respond = callback; },
hide: async () => { await act(async () => renderer.update(React.createElement(Panel, { visible: false }))); } };
}
const tooltip = '<div data-radix-popper-content-wrapper><div data-state="delayed-open"><span role="tooltip">Split vertically</span></div></div>';
test('a split-button tooltip keeps the browser picture, then restores its live view', async (t) => {
const f = await fixture(t);
assert.equal(f.layouts.at(-1)?.visible, true);
const hint = f.overlay(tooltip);
await f.tick();
assert.equal(f.layouts.at(-1)?.visible, true, 'the owning panel remains open');
assert.equal(f.layouts.at(-1)?.occluded, true);
assert.deepEqual(f.panel().snapshot, layers, 'the native page is replaced by its picture, not a blank pane');
const count = f.layouts.length;
await f.tick();
assert.equal(f.layouts.length, count, 'a stationary tooltip captures only once');
hint.remove(); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false);
assert.deepEqual(f.panel().snapshot, []);
});
test('unrelated and closed poppers leave the browser live', async (t) => {
const f = await fixture(t);
f.overlay(tooltip, 10, 10);
f.overlay('<div data-radix-popper-content-wrapper><div role="menu" data-state="closed"></div></div>');
await f.tick();
assert.equal(f.layouts.length, 1);
assert.equal(f.layouts.at(-1)?.occluded, false);
});
test('overlapping nonmodal menus yield with a picture; modal dialogs yield even beside the pane', async (t) => {
const f = await fixture(t);
const menu = f.overlay('<div data-radix-popper-content-wrapper><div role="dialog" data-state="open"></div></div>');
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
menu.remove(); await f.tick();
f.overlay('<div role="dialog" aria-modal="true"></div>', 0, 0);
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
});
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
const f = await fixture(t);
let complete: (result: Result) => void = () => assert.fail('capture was not started');
f.respond((layout) => layout.occluded ? new Promise((resolve) => { complete = resolve; }) : Promise.resolve(true));
const menu = f.overlay(tooltip); await f.tick();
menu.remove(); await f.tick();
await act(async () => complete({ layers }));
assert.deepEqual(f.panel().snapshot, []);
});
test('hiding or locking the pane clears pictures without taking another capture', async (t) => {
const f = await fixture(t);
f.overlay(tooltip); await f.tick();
assert.deepEqual(f.panel().snapshot, layers);
f.overlay('<div data-app-lock-overlay role="dialog"></div>'); await f.tick();
assert.equal(f.layouts.at(-1)?.visible, false);
assert.equal(f.layouts.at(-1)?.occluded, false);
assert.deepEqual(f.panel().snapshot, []);
await f.hide();
assert.deepEqual(f.panel().snapshot, []);
});
+8 -36
View File
@@ -2,16 +2,6 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
const overlay = element.getBoundingClientRect();
return overlay.width > 0 && overlay.height > 0
&& overlay.x < rect.right && overlay.right > rect.x
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
}
interface BrowserPanelOptions {
browserId: string;
@@ -32,47 +22,32 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const mountedBrowserId = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
const [readyId, setReadyId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<{ browserId: string; layers: HostBrowserPanelSnapshotLayer[] } | null>(null);
const { browserId, isVisible } = options;
useLayoutEffect(() => {
const bridge = netcattyBridge.get();
let frame = 0;
let previous = '';
let disposed = false;
const update = () => {
const element = elementRef.current;
const rect = element?.getBoundingClientRect();
// Native views sit above renderer portals. Only overlapping poppers need
// to yield; dialogs are modal even when their content is beside the pane.
const locked = !!document.querySelector('[data-app-lock-overlay]');
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
const covered = modal || (!!rect && Array.from(document.querySelectorAll(
'[role="dialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
)).some((overlay) => overlapsBrowser(rect, overlay)));
// Native page views must leave the screen with their owning pane and
// must not cover a renderer dialog or a toolbar's open menu.
const covered = !!document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper]');
const visible = isVisible && !!element?.isConnected && !!rect
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
if (!visible) setSnapshot(null);
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !covered;
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
browserId, visible, occluded: visible && covered, bounds, theme, themeTokens,
};
const next = JSON.stringify(layout);
const next = JSON.stringify({ browserId, visible, bounds, theme, themeTokens });
if (next !== previous) {
previous = next;
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
if (disposed || previous !== next) return;
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
}).catch(() => {});
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible, bounds, theme, themeTokens }).catch(() => {});
}
if (isVisible) frame = requestAnimationFrame(update);
};
update();
return () => {
disposed = true;
cancelAnimationFrame(frame);
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
};
@@ -83,9 +58,7 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
opened.current = true;
setError(null);
let cancelled = false;
void mountHostBrowser({ ...optionsRef.current, browserId }).then(() => {
if (mountedBrowserId.current === browserId) setReadyId(browserId);
}).catch((reason: unknown) => {
void mountHostBrowser({ ...optionsRef.current, browserId }).catch((reason: unknown) => {
opened.current = false;
if (cancelled) return;
setError(reason instanceof Error ? reason.message : String(reason));
@@ -107,6 +80,5 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
};
}, [browserId]);
return { elementRef, error, ready: readyId === browserId, snapshot: isVisible && snapshot?.browserId === browserId ? snapshot.layers : [],
retry: () => setAttempt((value) => value + 1) };
return { elementRef, error, retry: () => setAttempt((value) => value + 1) };
}
-103
View File
@@ -1,103 +0,0 @@
import { useEffect, useRef, useState, type PointerEvent } from "react";
import {
clampMonitorColumnWidth,
normalizeMonitorColumnWidths,
normalizeMonitorHiddenColumns,
type MonitorColumn,
} from "../../domain/monitorTableLayout";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
STORAGE_KEY_MONITOR_HIDDEN_COLUMNS,
} from "../../infrastructure/config/storageKeys";
export function useMonitorTableColumns() {
const [widths, setWidths] = useState(() =>
normalizeMonitorColumnWidths(
localStorageAdapter.read(STORAGE_KEY_MONITOR_TABLE_COLUMNS),
),
);
const [hidden, setHidden] = useState(() =>
normalizeMonitorHiddenColumns(
localStorageAdapter.read(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS),
),
);
const currentHidden = useRef(hidden);
const setVisible = (key: MonitorColumn, visible: boolean) => {
const previous = currentHidden.current;
const next = normalizeMonitorHiddenColumns(
visible ? previous.filter((item) => item !== key) : [...previous, key],
);
currentHidden.current = next;
setHidden(next);
localStorageAdapter.write(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS, next);
};
const current = useRef(widths);
const cleanup = useRef<(() => void) | null>(null);
useEffect(() => () => cleanup.current?.(), []);
const resize = (key: MonitorColumn, width: number, persist = true) => {
current.current = {
...current.current,
[key]: clampMonitorColumnWidth(key, width),
};
setWidths(current.current);
if (persist)
localStorageAdapter.write(
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
current.current,
);
};
const startResize = (
key: MonitorColumn,
event: PointerEvent<HTMLElement>,
) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
cleanup.current?.();
const pointerId = event.pointerId;
const handle = event.currentTarget;
handle.setPointerCapture(pointerId);
const start = event.clientX;
const width = current.current[key];
const cursor = document.body.style.cursor;
const userSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const move = (next: globalThis.PointerEvent) => {
if (next.pointerId === pointerId)
resize(key, width + next.clientX - start, false);
};
const finish = (next: globalThis.PointerEvent) => {
if (next.pointerId !== pointerId) return;
localStorageAdapter.write(
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
current.current,
);
cleanup.current?.();
};
const blur = () => {
localStorageAdapter.write(
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
current.current,
);
cleanup.current?.();
};
cleanup.current = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", finish);
window.removeEventListener("pointercancel", finish);
window.removeEventListener("blur", blur);
if (handle.hasPointerCapture(pointerId))
handle.releasePointerCapture(pointerId);
document.body.style.cursor = cursor;
document.body.style.userSelect = userSelect;
cleanup.current = null;
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", finish);
window.addEventListener("pointercancel", finish);
window.addEventListener("blur", blur);
};
return { widths, resize, startResize, hidden, setVisible };
}
+9 -130
View File
@@ -1,137 +1,16 @@
import React, { useEffect, useRef, useState } from 'react';
import { ArrowLeft, ArrowRight, Bookmark, Download, Edit2, Loader2, Plus, RotateCw, Square, Trash2, X } from 'lucide-react';
import React from 'react';
import { useHostBrowserPanel } from '../application/state/useHostBrowserPanel';
import { useHostBrowserControls } from '../application/state/useHostBrowserControls';
import { useI18n } from '../application/i18n/I18nProvider';
import type { BrowserBookmark } from '../domain/browserBookmark';
import type { HostBrowserTab } from '../domain/hostBrowser';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
import { BrowserIcon } from './host-browser/BrowserIcon';
import { BrowserBookmarkEditor } from './host-browser/BrowserBookmarkEditor';
import { BrowserDownloads } from './host-browser/BrowserDownloads';
import { BrowserIconEditor, type BrowserIconSelection } from './host-browser/BrowserIconEditor';
function ToolbarButton({ label, children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
return <Tooltip><TooltipTrigger asChild><button type="button" aria-label={label} {...props} className={cn('grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-secondary/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-35', props.className)}>{children}</button></TooltipTrigger><TooltipContent side="top">{label}</TooltipContent></Tooltip>;
}
export function HostBrowserSidePanel(props: Parameters<typeof useHostBrowserPanel>[0]) {
const { elementRef, error: mountError, snapshot, retry, ready } = useHostBrowserPanel(props);
const browser = useHostBrowserControls(props.browserId, ready && props.isVisible);
const { elementRef, error, retry } = useHostBrowserPanel(props);
const { t } = useI18n();
const active = browser.state?.tabs.find((tab) => tab.id === browser.state?.activeTabId);
const connected = browser.state?.route === 'connected';
const [address, setAddress] = useState('');
const addressRef = useRef<HTMLInputElement>(null);
const lastActive = useRef<string | undefined>(undefined);
const [bookmarksOpen, setBookmarksOpen] = useState(false);
const [downloadsOpen, setDownloadsOpen] = useState(false);
const [editingBookmark, setEditingBookmark] = useState<Partial<BrowserBookmark> | null>(null);
const [editingIcon, setEditingIcon] = useState<HostBrowserTab | null>(null);
useEffect(() => {
if (lastActive.current !== active?.id || document.activeElement !== addressRef.current) setAddress(active?.url || '');
lastActive.current = active?.id;
}, [active?.id, active?.url]);
useEffect(() => {
if (!browser.focusAddress) return;
addressRef.current?.focus(); addressRef.current?.select();
}, [browser.focusAddress]);
useEffect(() => { setEditingBookmark(null); setEditingIcon(null); setBookmarksOpen(false); setDownloadsOpen(false); }, [props.browserId]);
useEffect(() => {
if (browser.downloadStarted) { setDownloadsOpen(true); setBookmarksOpen(false); }
}, [browser.downloadStarted]);
const newBookmark = () => {
setBookmarksOpen(true);
setEditingBookmark({ title: active?.url ? active.title || '' : '', url: active?.url || '', scope: 'host', color: '#247969', order: browser.bookmarks.length });
};
const navigate = (url: string) => { void browser.command({ type: 'navigate', url }); };
const error = mountError || browser.error;
return <div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background text-foreground" data-section="host-browser-root" onKeyDownCapture={(event) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
const key = event.key.toLowerCase();
if (!['l', 't', 'w', 'r'].includes(key)) return;
event.preventDefault(); event.stopPropagation();
if (key === 'l') browser.requestAddressFocus();
if (key === 't') void browser.command({ type: 'new-tab' });
if (key === 'w') void browser.command({ type: 'close-tab' });
if (key === 'r') void browser.command({ type: 'reload' });
}}>
<div className="shrink-0 border-b border-border/50" data-section="host-browser-controls">
<div className="flex min-w-0 items-center gap-0.5 px-1.5 pt-1">
<div role="tablist" aria-label={t('terminal.browser.title')} className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto">
{browser.state?.tabs.map((tab) => <ContextMenu key={tab.id}><ContextMenuTrigger asChild><div role="tab" tabIndex={tab.id === active?.id ? 0 : -1} aria-selected={tab.id === active?.id} draggable
className={cn('group flex h-7 min-w-[90px] max-w-[200px] flex-1 cursor-default items-center gap-1.5 rounded px-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring', tab.id === active?.id ? 'bg-secondary/65 text-foreground' : 'text-muted-foreground hover:bg-secondary/30')}
onClick={() => void browser.command({ type: 'activate', tabId: tab.id })}
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); void browser.command({ type: 'activate', tabId: tab.id }); } }}
onDragStart={(event) => { event.dataTransfer.setData('text/plain', tab.id); event.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }}
onDrop={(event) => { event.preventDefault(); const id = event.dataTransfer.getData('text/plain'); if (browser.state?.tabs.some((item) => item.id === id)) void browser.command({ type: 'reorder', tabId: id, beforeTabId: tab.id }); }}>
{tab.loading ? <Loader2 size={13} className="shrink-0 animate-spin" /> : <BrowserIcon image={tab.iconDataUrl} glyph={tab.iconGlyph} color={tab.iconColor} className="h-3.5 w-3.5" />}
<span className="min-w-0 flex-1 truncate" title={tab.title || tab.url}>{tab.url ? tab.title || tab.url : t('terminal.browser.newTab')}</span>
<button type="button" aria-label={t('terminal.browser.closeTab')} className="grid h-4 w-4 shrink-0 place-items-center rounded opacity-60 hover:bg-muted hover:opacity-100" onClick={(event) => { event.stopPropagation(); void browser.command({ type: 'close-tab', tabId: tab.id }); }}><X size={11} /></button>
</div></ContextMenuTrigger><ContextMenuContent>
<ContextMenuItem disabled={!tab.url} onClick={() => setEditingIcon(tab)}>{t('terminal.browser.changeIcon')}</ContextMenuItem>
<ContextMenuItem disabled={!tab.url} onClick={() => void browser.command({ type: 'reset-icon', tabId: tab.id })}>{t('terminal.browser.resetIcon')}</ContextMenuItem>
</ContextMenuContent></ContextMenu>)}
</div>
<ToolbarButton label={t('terminal.browser.newTab')} disabled={!ready} onClick={() => void browser.command({ type: 'new-tab' })}><Plus size={14} /></ToolbarButton>
</div>
<div className="flex min-w-0 items-center gap-0.5 px-1.5 py-1">
<ToolbarButton label={t('terminal.browser.back')} disabled={!connected || !active?.canGoBack} onClick={() => void browser.command({ type: 'back' })}><ArrowLeft size={14} /></ToolbarButton>
<ToolbarButton label={t('terminal.browser.forward')} disabled={!connected || !active?.canGoForward} onClick={() => void browser.command({ type: 'forward' })}><ArrowRight size={14} /></ToolbarButton>
<ToolbarButton label={t(active?.loading ? 'terminal.browser.stop' : 'terminal.browser.reload')} disabled={!connected || !active?.url} onClick={() => void browser.command({ type: active?.loading ? 'stop' : 'reload' })}>{active?.loading ? <Square size={12} /> : <RotateCw size={14} />}</ToolbarButton>
<form className="mx-1 min-w-0 flex-1" onSubmit={(event) => { event.preventDefault(); navigate(address); addressRef.current?.blur(); }}><Input ref={addressRef} aria-label={t('terminal.browser.address')} placeholder={t('terminal.browser.address')} value={address} onChange={(event) => setAddress(event.target.value)} autoComplete="off" spellCheck={false} className="h-7 min-w-0 rounded-md border-border/50 bg-secondary/35 px-2 text-xs" /></form>
<ToolbarButton label={t('terminal.browser.bookmarks')} aria-pressed={bookmarksOpen} className={bookmarksOpen ? 'bg-secondary/70 text-foreground' : ''} onClick={() => { setBookmarksOpen((value) => !value); setDownloadsOpen(false); }}><Bookmark size={14} /></ToolbarButton>
<Popover open={downloadsOpen} onOpenChange={setDownloadsOpen}><Tooltip><TooltipTrigger asChild><PopoverTrigger asChild><button type="button" aria-label={t('terminal.browser.downloads')} className="relative grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-secondary/70 hover:text-foreground"><Download size={14} />{browser.downloads.some((item) => ['waiting', 'progressing'].includes(item.status)) && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-primary" />}</button></PopoverTrigger></TooltipTrigger><TooltipContent side="top">{t('terminal.browser.downloads')}</TooltipContent></Tooltip><PopoverContent align="end" className="w-[min(380px,calc(100vw-24px))] p-0"><BrowserDownloads items={browser.downloads} command={browser.downloadCommand} /></PopoverContent></Popover>
</div>
return (
<div ref={elementRef} className="relative h-full min-h-0 w-full overflow-hidden" data-section="host-browser-panel" data-browser-host-id={props.host.id}>
{error && <div className="p-4 text-sm text-muted-foreground">
<p>{error}</p>
<button type="button" className="mt-3 text-primary" onClick={retry}>{t('common.retry')}</button>
</div>}
</div>
{error && <div role="status" className="flex shrink-0 items-center gap-2 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground"><span className="min-w-0 flex-1">{error}</span>{mountError ? <Button size="sm" variant="ghost" onClick={retry}>{t('terminal.browser.retry')}</Button> : <button type="button" aria-label={t('common.close')} onClick={browser.dismissError}><X size={13} /></button>}</div>}
<div className="flex min-h-0 flex-1 overflow-hidden">
<div ref={elementRef} className="relative min-h-0 min-w-0 flex-1 overflow-hidden" data-section="host-browser-panel" data-browser-host-id={props.host.id}>
{snapshot.map((layer, index) => <img key={index} src={layer.image} alt="" aria-hidden="true" draggable={false} className="absolute pointer-events-none select-none" style={{ left: layer.bounds.x, top: layer.bounds.y, width: layer.bounds.width, height: layer.bounds.height }} />)}
{!connected ? <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center text-xs text-muted-foreground"><p>{t(browser.state?.route === 'disconnected' ? 'terminal.browser.disconnected' : 'terminal.browser.connecting')}</p>{browser.state?.error && <p>{browser.state.error}</p>}{browser.state?.route === 'disconnected' && <Button size="sm" variant="secondary" onClick={() => void browser.command({ type: 'retry' })}>{t('terminal.browser.retry')}</Button>}</div>
: !active?.url && <div className="h-full overflow-y-auto p-4"><div className="mb-3 flex items-center justify-between gap-2 text-xs text-muted-foreground"><span>{t('terminal.browser.bookmarks')}</span><Button size="sm" variant="ghost" className="h-7 text-xs" onClick={newBookmark}><Plus size={12} className="mr-1" />{t('terminal.browser.addBookmark')}</Button></div>{!browser.bookmarks.length ? <p className="py-8 text-center text-xs text-muted-foreground">{t('terminal.browser.empty')}</p> : <div className="grid grid-cols-[repeat(auto-fill,minmax(110px,1fr))] gap-1">{browser.bookmarks.map((item) => <button type="button" key={item.id} title={item.url} className="flex min-w-0 flex-col items-center gap-2 rounded-lg p-3 hover:bg-secondary/50" onClick={() => navigate(item.url)}><BrowserIcon className="h-9 w-9 rounded-lg bg-secondary/60 p-1.5" image={item.iconMode === 'custom' ? item.iconDataUrl : item.siteIconDataUrl} glyph={item.iconMode === 'custom' ? item.icon : item.siteIconGlyph || item.icon} color={item.color} /><span className="w-full truncate text-xs">{item.title}</span></button>)}</div>}</div>}
</div>
{bookmarksOpen && <aside className="flex w-[380px] max-w-full shrink-0 flex-col border-l border-border/50 bg-background" aria-label={t('terminal.browser.bookmarks')}>
<div className="flex min-h-12 shrink-0 items-center justify-between gap-2 border-b border-border/60 px-4 py-3"><span className="truncate text-sm font-semibold">{t(editingBookmark ? editingBookmark.id ? 'terminal.browser.editBookmark' : 'terminal.browser.addBookmark' : 'terminal.browser.bookmarks')}</span><div className="flex items-center gap-1">{!editingBookmark && <ToolbarButton label={t('terminal.browser.addBookmark')} onClick={newBookmark}><Plus size={13} /></ToolbarButton>}<ToolbarButton label={t('common.close')} onClick={() => setBookmarksOpen(false)}><X size={13} /></ToolbarButton></div></div>
{editingBookmark ? <BrowserBookmarkEditor key={editingBookmark.id || 'new'} initial={editingBookmark} onSave={browser.mutateBookmark} onCancel={() => setEditingBookmark(null)} readIcon={browser.readIcon} /> : <div className="min-h-0 flex-1 overflow-y-auto p-1">{browser.bookmarks.map((item) => <div key={item.id} className="group flex min-w-0 items-center gap-2 rounded-md px-2 py-2 hover:bg-secondary/40"><BrowserIcon className="h-6 w-6 rounded" image={item.iconMode === 'custom' ? item.iconDataUrl : item.siteIconDataUrl} glyph={item.iconMode === 'custom' ? item.icon : item.siteIconGlyph || item.icon} color={item.color} /><button type="button" className="min-w-0 flex-1 text-left" onClick={() => navigate(item.url)}><p className="truncate text-xs">{item.title}</p><p className="truncate text-[11px] text-muted-foreground">{item.url}</p></button><ToolbarButton label={t('common.edit')} onClick={() => setEditingBookmark(item)}><Edit2 size={12} /></ToolbarButton><ToolbarButton label={t('common.delete')} onClick={() => void browser.mutateBookmark(item.id)}><Trash2 size={12} /></ToolbarButton></div>)}</div>}
</aside>}
</div>
{editingIcon && <BrowserTabIconEditor key={editingIcon.id} tab={editingIcon} readIcon={browser.readIcon} onClose={() => setEditingIcon(null)} onSave={async (values) => { const saved = await browser.command({ type: 'set-icon', tabId: editingIcon.id, ...values }); if (saved) setEditingIcon(null); }} />}
</div>;
}
function BrowserTabIconEditor({ tab, readIcon, onClose, onSave }: { tab: HostBrowserTab; readIcon: (file: File) => Promise<string | null>; onClose: () => void; onSave: (values: { glyph: string; color: string; dataUrl?: string | null }) => Promise<void> }) {
const { t } = useI18n();
const [icon, setIcon] = useState<BrowserIconSelection>({ mode: tab.iconDataUrl ? 'image' : 'text', glyph: tab.iconGlyph || '', color: tab.iconColor || '#247969', image: tab.iconDataUrl });
const [saving, setSaving] = useState(false);
const [reading, setReading] = useState(false);
return <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-w-sm">
<DialogTitle>{t('terminal.browser.changeIcon')}</DialogTitle>
<form className="space-y-4" onSubmit={async (event) => {
event.preventDefault(); setSaving(true);
try { await onSave({ glyph: icon.mode === 'text' ? icon.glyph : '', color: icon.color, dataUrl: icon.mode === 'image' ? icon.image : null }); }
finally { setSaving(false); }
}}>
<div className="flex min-w-0 items-center gap-3">
<BrowserIcon className="h-12 w-12 rounded-xl border border-border/50 bg-muted/40 p-2.5 [&>span]:text-lg" image={icon.mode === 'image' ? icon.image : null} glyph={icon.mode === 'text' ? icon.glyph : null} color={icon.color} />
<p className="min-w-0 truncate text-sm font-medium">{tab.title || tab.url}</p>
</div>
<BrowserIconEditor value={icon} onChange={(changes) => setIcon((current) => ({ ...current, ...changes }))} readIcon={readIcon} onBusyChange={setReading} allowWebsite={false} />
<div className="flex justify-end gap-2 border-t border-border/50 pt-3">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>{t('common.cancel')}</Button>
<Button type="submit" size="sm" disabled={saving || reading || (icon.mode === 'image' ? !icon.image : !icon.glyph.trim())}>{t('common.save')}</Button>
</div>
</form>
</DialogContent>
</Dialog>;
);
}
@@ -1,82 +0,0 @@
import React, { useId, useState } from 'react';
import { Check, Globe, Loader2, Monitor } from 'lucide-react';
import type { BrowserBookmark } from '../../domain/browserBookmark';
import type { HostBrowserBookmark } from '../../domain/hostBrowser';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { AsidePanelContent, AsidePanelFooter } from '../ui/aside-panel';
import { BrowserIcon } from './BrowserIcon';
import { BrowserIconEditor, type BrowserIconSelection } from './BrowserIconEditor';
export function BrowserBookmarkEditor({ initial, onSave, onCancel, readIcon }: {
initial: Partial<HostBrowserBookmark>;
onSave: (bookmark: Partial<BrowserBookmark>) => Promise<boolean>;
onCancel: () => void;
readIcon: (file: File) => Promise<string | null>;
}) {
const { t } = useI18n();
const id = useId();
const [draft, setDraft] = useState(initial);
const [icon, setIcon] = useState<BrowserIconSelection>({
mode: initial.iconDataUrl ? 'image' : initial.iconMode === 'custom' ? 'text' : 'website',
glyph: initial.icon || '', color: initial.color || '#247969', image: initial.iconDataUrl || null,
});
const [reading, setReading] = useState(false);
const [saving, setSaving] = useState(false);
const invalidIcon = reading || (icon.mode === 'image' && !icon.image);
const previewImage = icon.mode === 'image' ? icon.image : icon.mode === 'website' ? initial.siteIconDataUrl : null;
const previewGlyph = icon.mode === 'text' ? icon.glyph || draft.title?.trim().slice(0, 1) : icon.mode === 'website' ? initial.siteIconGlyph : null;
let addressHint = t('terminal.browser.bookmarkPreviewHint');
try { if (draft.url) addressHint = new URL(draft.url).host; } catch { /* Keep the hint while an address is being typed. */ }
return <form className="flex min-h-0 flex-1 flex-col" data-section="browser-bookmark-editor" onSubmit={async (event) => {
event.preventDefault();
if (invalidIcon || saving) return;
setSaving(true);
try {
const saved = await onSave({ ...draft, title: draft.title?.trim(), url: draft.url?.trim(),
color: icon.color, iconMode: icon.mode === 'website' ? 'auto' : 'custom',
iconDataUrl: icon.mode === 'image' ? icon.image : null, icon: icon.mode === 'text' ? icon.glyph : '' });
if (saved) onCancel();
} finally { setSaving(false); }
}}>
<AsidePanelContent className="min-h-0">
<div className="flex min-w-0 items-center gap-3 border-b border-border/40 pb-4" data-section="bookmark-preview">
<BrowserIcon className="h-12 w-12 rounded-xl border border-border/50 bg-muted/40 p-2.5 [&>span]:text-lg [&>svg]:h-5 [&>svg]:w-5" image={previewImage} glyph={previewGlyph} color={icon.mode === 'website' ? initial.siteIconColor : icon.color} />
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{draft.title?.trim() || t('terminal.browser.untitledBookmark')}</p><p className="mt-1 truncate text-xs text-muted-foreground">{addressHint}</p></div>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-title`} className="text-xs text-muted-foreground">{t('terminal.browser.bookmarkTitle')}</Label>
<Input id={`${id}-title`} autoFocus required maxLength={120} className="h-9" placeholder={t('terminal.browser.bookmarkTitlePlaceholder')} value={draft.title || ''} onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-url`} className="text-xs text-muted-foreground">{t('terminal.browser.websiteAddress')}</Label>
<Input id={`${id}-url`} required type="url" className="h-9" placeholder="https://example.com" spellCheck={false} autoComplete="off" value={draft.url || ''} onChange={(event) => setDraft((current) => ({ ...current, url: event.target.value }))} />
</div>
<div className="space-y-2.5">
<p className="text-xs font-medium text-muted-foreground">{t('terminal.browser.icon')}</p>
<BrowserIconEditor value={icon} onChange={(changes) => setIcon((current) => ({ ...current, ...changes }))} readIcon={readIcon} onBusyChange={setReading} />
</div>
<fieldset className="min-w-0 space-y-2.5">
<legend className="mb-2.5 text-xs font-medium text-muted-foreground">{t('terminal.browser.scope')}</legend>
{(['host', 'all-hosts'] as const).map((scope) => {
const selected = (draft.scope || 'host') === scope;
const Icon = scope === 'host' ? Monitor : Globe;
return <label key={scope} className={cn('relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors focus-within:ring-1 focus-within:ring-ring', selected ? 'border-primary/35 bg-primary/5' : 'border-border/50 hover:bg-muted/30')}>
<input type="radio" name={`${id}-scope`} value={scope} checked={selected} onChange={() => setDraft((current) => ({ ...current, scope }))} className="sr-only" />
<Icon size={15} className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground')} />
<span className="min-w-0 flex-1"><span className="block text-xs font-medium">{t(scope === 'host' ? 'terminal.browser.hostScope' : 'terminal.browser.allScope')}</span><span className="mt-1 block text-[11px] leading-relaxed text-muted-foreground">{t(scope === 'host' ? 'terminal.browser.hostScopeHint' : 'terminal.browser.allScopeHint')}</span></span>
<span className={cn('grid h-4 w-4 shrink-0 place-items-center rounded-full border', selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40')}>{selected && <Check size={10} />}</span>
</label>;
})}
</fieldset>
</AsidePanelContent>
<AsidePanelFooter className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>{t('common.cancel')}</Button>
<Button type="submit" size="sm" className="min-w-24" disabled={saving || invalidIcon}>{saving && <Loader2 size={13} className="mr-1.5 animate-spin" />}{t(initial.id ? 'common.save' : 'terminal.browser.addBookmark')}</Button>
</AsidePanelFooter>
</form>;
}
@@ -1,41 +0,0 @@
import React from 'react';
import { File, FolderOpen, MoreHorizontal, X } from 'lucide-react';
import type { HostBrowserDownload } from '../../domain/hostBrowser';
import type { useHostBrowserControls } from '../../application/state/useHostBrowserControls';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
function size(bytes: number) {
const units = ['B', 'KB', 'MB', 'GB'];
const index = bytes > 0 ? Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024))) : 0;
return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`;
}
export function BrowserDownloads({ items, command }: { items: HostBrowserDownload[]; command: ReturnType<typeof useHostBrowserControls>['downloadCommand'] }) {
const { t } = useI18n();
return <div className="min-w-0">
<div className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2"><div className="min-w-0"><p className="text-xs font-medium">{t('terminal.browser.downloads')}</p><p className="mt-1 text-[11px] text-muted-foreground">{t('terminal.browser.downloadLocation')}</p></div><Button size="icon" variant="ghost" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.openFolder')} title={t('terminal.browser.openFolder')} onClick={() => void command('open-folder')}><FolderOpen size={14} /></Button></div>
<div className="max-h-80 overflow-y-auto p-1">
{!items.length && <p className="px-3 py-8 text-center text-xs text-muted-foreground">{t('terminal.browser.downloadEmpty')}</p>}
{items.map((item) => {
const running = item.status === 'waiting' || item.status === 'progressing';
const canOpen = item.status === 'completed' && !item.missing;
return <div key={item.id} data-download-id={item.id} className="flex min-w-0 items-center gap-2 rounded-md px-2 py-2 hover:bg-secondary/50">
<File size={16} className="shrink-0 text-muted-foreground" /><div className="min-w-0 flex-1"><p className="truncate text-xs" title={item.filename}>{item.filename}</p><p className="mt-0.5 text-[11px] text-muted-foreground">{t(`terminal.browser.${item.missing ? 'missing' : item.status}`)}{item.status === 'progressing' ? ` · ${size(item.received)}${item.total ? ` / ${size(item.total)}` : ''}` : item.status === 'completed' ? ` · ${size(item.total || item.received)}` : ''}</p>
{item.status === 'progressing' && <div className="mt-1 h-1 overflow-hidden rounded bg-secondary"><div className={`h-full bg-primary${item.total ? '' : ' animate-pulse'}`} style={{ width: `${item.total ? Math.min(100, item.received * 100 / item.total) : 20}%` }} /></div>}</div>
{running ? <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.cancelDownload')} title={t('terminal.browser.cancelDownload')} onClick={() => void command('cancel', item.id)}><X size={13} /></Button>
: canOpen ? <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" title={t('terminal.browser.reveal')} onClick={() => void command('reveal', item.id)}><FolderOpen size={13} /></Button> : null}
<Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.more')}><MoreHorizontal size={13} /></Button></PopoverTrigger><PopoverContent align="end" className="w-40 p-1">
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('open', item.id)}>{t('terminal.browser.openFile')}</Button>}
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('save-as', item.id)}>{t('terminal.browser.saveAs')}</Button>}
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('reveal', item.id)}>{t('terminal.browser.reveal')}</Button>}
{!running && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('remove', item.id)}>{t('terminal.browser.removeDownload')}</Button>}
{running && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('cancel', item.id)}>{t('terminal.browser.cancelDownload')}</Button>}
</PopoverContent></Popover>
</div>;
})}
</div>
{items.some((item) => !['waiting', 'progressing'].includes(item.status)) && <div className="border-t border-border/50 p-1"><Button variant="ghost" size="sm" className="w-full text-xs" onClick={() => void command('clear')}>{t('terminal.browser.clearDownloads')}</Button></div>}
</div>;
}
-12
View File
@@ -1,12 +0,0 @@
import React from 'react';
import { Globe } from 'lucide-react';
export function BrowserIcon({ image, glyph, color, className = '' }: {
image?: string | null; glyph?: string | null; color?: string | null; className?: string;
}) {
return <span className={`inline-flex shrink-0 items-center justify-center overflow-hidden ${className}`}>
{image ? <img src={image} alt="" className="h-full w-full rounded-[20%] object-contain" draggable={false} />
: glyph ? <span className="truncate text-xs" style={{ color: color || undefined }}>{glyph}</span>
: <Globe size={14} className="text-muted-foreground" />}
</span>;
}
@@ -1,103 +0,0 @@
import React, { useEffect, useId, useRef, useState } from 'react';
import { Check, Globe, ImagePlus, Loader2, Pipette, Type, Upload, X } from 'lucide-react';
import { HostBrowserIconError } from '../../domain/hostBrowser';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
export interface BrowserIconSelection {
mode: 'website' | 'text' | 'image';
glyph: string;
color: string;
image: string | null;
}
const COLORS = ['#247969', '#2563eb', '#7c3aed', '#c026d3', '#e11d48', '#ea580c', '#ca8a04', '#64748b'];
const MODES = [{ value: 'website', Icon: Globe }, { value: 'text', Icon: Type }, { value: 'image', Icon: ImagePlus }] as const;
export function BrowserIconEditor({ value, onChange, readIcon, onBusyChange, allowWebsite = true }: {
value: BrowserIconSelection;
onChange: (changes: Partial<BrowserIconSelection>) => void;
readIcon: (file: File) => Promise<string | null>;
onBusyChange: (busy: boolean) => void;
allowWebsite?: boolean;
}) {
const { t } = useI18n();
const inputId = useId();
const fileInput = useRef<HTMLInputElement>(null);
const readRevision = useRef(0);
const [busy, setBusy] = useState(false);
const [dragging, setDragging] = useState(false);
const [filename, setFilename] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => () => { readRevision.current++; }, []);
const selectFile = async (file?: File) => {
if (!file) return;
const revision = ++readRevision.current;
setError(null); setBusy(true); onBusyChange(true);
try {
const image = await readIcon(file);
if (revision !== readRevision.current) return;
if (!image) throw new HostBrowserIconError('decode');
onChange({ image }); setFilename(file.name);
} catch (reason) {
if (revision === readRevision.current) {
const key = reason instanceof HostBrowserIconError ? reason.reason : 'decode';
setError(t(`terminal.browser.iconError.${key}`));
}
} finally {
if (revision === readRevision.current) { setBusy(false); onBusyChange(false); }
}
};
return <div className="space-y-3" data-section="browser-icon-editor">
<div className="grid grid-flow-col auto-cols-fr gap-1 rounded-md bg-muted/50 p-1" role="group" aria-label={t('terminal.browser.icon')}>
{MODES.filter((mode) => allowWebsite || mode.value !== 'website').map(({ value: mode, Icon }) => <button
key={mode} type="button" aria-pressed={value.mode === mode} disabled={busy}
className={cn('flex min-w-0 items-center justify-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50',
value.mode === mode ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')}
onClick={() => { onChange({ mode }); setError(null); }}>
<Icon size={13} className="shrink-0" /><span className="truncate">{t(`terminal.browser.${mode}`)}</span>
</button>)}
</div>
{value.mode === 'website' && <p className="text-xs leading-relaxed text-muted-foreground">{t('terminal.browser.websiteIconHint')}</p>}
{value.mode === 'text' && <div className="space-y-3">
<div className="flex items-center gap-3">
<Input id={inputId} aria-label={t('terminal.browser.iconText')} className="h-9 w-20 shrink-0 text-center text-base" maxLength={4} value={value.glyph}
onChange={(event) => onChange({ glyph: event.target.value })} placeholder="A" />
<Label htmlFor={inputId} className="text-xs font-normal leading-relaxed text-muted-foreground">{t('terminal.browser.iconTextHint')}</Label>
</div>
<div className="flex flex-wrap items-center gap-2" role="group" aria-label={t('terminal.browser.color')}>
{COLORS.map((color) => <button key={color} type="button" aria-label={t('terminal.browser.selectColor', { color })} aria-pressed={value.color.toLowerCase() === color}
className={cn('grid h-6 w-6 place-items-center rounded-full border-2 border-background transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', value.color.toLowerCase() === color && 'ring-1 ring-foreground/40')}
style={{ backgroundColor: color }} onClick={() => onChange({ color })}>
{value.color.toLowerCase() === color && <Check size={12} className="text-white" />}
</button>)}
<label className="relative grid h-6 w-6 cursor-pointer place-items-center rounded-full border border-border text-muted-foreground hover:text-foreground focus-within:ring-1 focus-within:ring-ring" title={t('terminal.browser.customColor')}>
<Pipette size={12} /><input aria-label={t('terminal.browser.customColor')} type="color" className="absolute inset-0 h-full w-full cursor-pointer opacity-0" value={value.color} onChange={(event) => onChange({ color: event.target.value })} />
</label>
</div>
</div>}
{value.mode === 'image' && <div className="space-y-2" onDragOver={(event) => { event.preventDefault(); setDragging(true); }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); }}
onDrop={(event) => { event.preventDefault(); setDragging(false); if (!busy) void selectFile(event.dataTransfer.files[0]); }}>
<input ref={fileInput} type="file" className="hidden" aria-label={t('terminal.browser.chooseImage')}
accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon,image/vnd.microsoft.icon"
onChange={(event) => { void selectFile(event.target.files?.[0]); event.target.value = ''; }} />
{value.image ? <div className={cn('flex min-w-0 items-center gap-3 rounded-lg border border-border/60 bg-muted/20 p-3', dragging && 'border-primary bg-primary/5')}>
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-md border border-border/40 bg-background p-1.5"><img src={value.image} alt={t('terminal.browser.iconPreview')} className="h-full w-full rounded-[20%] object-contain" /></div>
<div className="min-w-0 flex-1"><p className="truncate text-xs font-medium" title={filename}>{filename || t('terminal.browser.savedImage')}</p><Button type="button" variant="link" size="sm" className="h-auto px-0 py-1 text-xs" disabled={busy} onClick={() => fileInput.current?.click()}>{busy ? t('terminal.browser.processingImage') : t('terminal.browser.replaceImage')}</Button></div>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground" disabled={busy} aria-label={t('terminal.browser.removeImage')} onClick={() => { onChange({ image: null }); setFilename(''); setError(null); }}><X size={14} /></Button>
</div> : <button type="button" disabled={busy} className={cn('flex w-full flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-muted/15 px-4 py-5 text-center transition-colors hover:border-primary/50 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', dragging && 'border-primary bg-primary/5')}
onClick={() => fileInput.current?.click()}>
<span className="grid h-8 w-8 place-items-center rounded-full bg-secondary/70 text-muted-foreground">{busy ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}</span>
<span className="text-xs font-medium">{t(busy ? 'terminal.browser.processingImage' : 'terminal.browser.uploadImage')}</span>
<span className="text-[11px] leading-relaxed text-muted-foreground">{t('terminal.browser.dropImage')}</span>
</button>}
<p className="text-[11px] leading-relaxed text-muted-foreground">{t('terminal.browser.imageHint')}</p>
</div>}
{error && <p role="alert" className="text-xs leading-relaxed text-destructive">{error}</p>}
</div>;
}
+1 -7
View File
@@ -145,16 +145,10 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
};
// Content Component (wraps children with scroll)
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string; scrollMode?: "custom" | "native" }> = ({
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({
children,
className,
scrollMode = "custom",
}) => {
if (scrollMode === "native") return (
<div data-aside-scroll-viewport className={cn("flex-1 min-h-0 min-w-0 overflow-y-auto overscroll-y-contain host-tree-notes-scroll", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-hidden">{children}</div>
</div>
);
return (
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
-275
View File
@@ -1,275 +0,0 @@
import React, { useCallback, useLayoutEffect, useRef, useState } from "react";
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";
import {
MONITOR_CARD_HEIGHT,
MONITOR_COLUMNS,
monitorGridColumns,
type MonitorColumn,
} from "../../domain/monitorTableLayout";
import { useMonitorTableColumns } from "../../application/state/useMonitorTableColumns";
import { useI18n } from "../../application/i18n/I18nProvider";
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuCheckboxItem,
ContextMenuLabel,
} from "../ui/context-menu";
export function MonitorHostCollection<T extends { id: string }>({
items,
view,
layoutKey,
pinnedId,
renderCard,
renderCells,
rowProps,
renderMenu,
}: {
items: T[];
view: "list" | "charts";
layoutKey: string;
pinnedId?: string;
renderCard: (item: T) => React.ReactNode;
renderCells: (item: T) => Record<MonitorColumn, React.ReactNode>;
rowProps: (item: T) => React.HTMLAttributes<HTMLTableRowElement>;
renderMenu: (item: T) => React.ReactNode;
}) {
const { t } = useI18n();
const scrollRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(1248);
const { widths, resize, startResize, hidden, setVisible } =
useMonitorTableColumns();
const visibleColumns = MONITOR_COLUMNS.filter(
({ key }) => !hidden.includes(key),
);
const columns =
view === "charts" ? monitorGridColumns(width, items.length) : 1;
const scrollMargin = view === "charts" ? 16 : 56;
const pinnedIndex = pinnedId
? items.findIndex((item) => item.id === pinnedId)
: -1;
const getItemKey = useCallback(
(index: number) => items[index * columns]?.id ?? index,
[items, columns],
);
const virtualizer = useVirtualizer<HTMLDivElement, HTMLElement>({
count: Math.ceil(items.length / columns),
getScrollElement: () => scrollRef.current,
estimateSize: () => (view === "charts" ? MONITOR_CARD_HEIGHT : 76),
gap: view === "charts" ? 16 : 8,
overscan: view === "charts" ? 2 : 5,
getItemKey,
scrollMargin,
initialRect: { width: 1280, height: 800 },
rangeExtractor: useCallback(
(range) => {
const visible = defaultRangeExtractor(range);
const pinned = Math.floor(pinnedIndex / columns);
return pinnedIndex < 0 || visible.includes(pinned)
? visible
: [...visible, pinned].sort((a, b) => a - b);
},
[pinnedIndex, columns],
),
});
useLayoutEffect(() => {
const element = scrollRef.current;
if (!element) return;
const measure = () => setWidth(Math.max(0, element.clientWidth));
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
virtualizer.measure();
}, [columns, view, hidden, virtualizer]);
useLayoutEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = 0;
virtualizer.scrollToOffset(0);
}, [layoutKey, virtualizer]);
const template = visibleColumns
.map((column) => `${widths[column.key]}px`)
.join(" ");
const tableWidth = visibleColumns.reduce(
(sum, column) => sum + widths[column.key],
0,
);
return (
<div className="min-h-0 min-w-0 flex-1 px-4">
<div
ref={scrollRef}
className="h-full min-w-0 overflow-auto"
data-section="vault-monitor-list"
data-monitor-virtual={view}
>
<div className="py-4">
{view === "charts" ? (
<div
className="relative"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
data-monitor-virtual-row={row.index}
className="absolute left-0 top-0 grid w-full gap-4"
style={{
height: MONITOR_CARD_HEIGHT,
gridTemplateColumns: `repeat(${columns},minmax(0,1fr))`,
transform: `translateY(${row.start - scrollMargin}px)`,
}}
>
{items
.slice(row.index * columns, (row.index + 1) * columns)
.map((item) => (
<ContextMenu key={item.id}>
<ContextMenuTrigger asChild>
{renderCard(item)}
</ContextMenuTrigger>
<ContextMenuContent>
{renderMenu(item)}
</ContextMenuContent>
</ContextMenu>
))}
</div>
))}
</div>
) : (
<table
className="block text-xs"
style={{ width: tableWidth }}
aria-rowcount={items.length + 1}
>
<ContextMenu>
<ContextMenuTrigger asChild>
<thead className="sticky top-0 z-20 block bg-background text-muted-foreground">
<tr
className="grid h-10"
style={{ gridTemplateColumns: template }}
>
{visibleColumns.map((column) => (
<th
key={column.key}
data-monitor-column={column.key}
className={`relative flex min-w-0 items-center px-3 text-left text-[11px] font-medium${column.key === "terminal" ? " sticky right-0 z-10 bg-background" : ""}`}
>
<span
className="truncate"
title={t(`monitor.${column.key}`)}
>
{column.key === "terminal" ? (
<span className="sr-only">
{t("monitor.openTerminal")}
</span>
) : (
t(`monitor.${column.key}`)
)}
</span>
<div
role="separator"
aria-orientation="vertical"
aria-label={t("monitor.resizeColumn", {
column: t(`monitor.${column.key}`),
})}
aria-valuenow={widths[column.key]}
aria-valuemin={column.min}
aria-valuemax={
column.key === "terminal" ? 200 : 640
}
tabIndex={0}
className="absolute inset-y-1 right-0 w-2 cursor-col-resize touch-none outline-none after:absolute after:inset-y-1 after:right-0.5 after:w-px hover:after:bg-primary focus-visible:after:bg-primary"
onPointerDown={(event) =>
startResize(column.key, event)
}
onDoubleClick={() =>
resize(column.key, column.width)
}
onKeyDown={(event) => {
if (
event.key === "ArrowLeft" ||
event.key === "ArrowRight"
) {
event.preventDefault();
resize(
column.key,
widths[column.key] +
(event.key === "ArrowRight" ? 1 : -1) *
(event.shiftKey ? 32 : 8),
);
}
}}
/>
</th>
))}
</tr>
</thead>
</ContextMenuTrigger>
<ContextMenuContent data-monitor-column-menu>
<ContextMenuLabel>
{t("monitor.visibleColumns")}
</ContextMenuLabel>
{MONITOR_COLUMNS.map(({ key }) => (
<ContextMenuCheckboxItem
key={key}
data-monitor-toggle-column={key}
checked={!hidden.includes(key)}
disabled={key === "host" || key === "terminal"}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) =>
setVisible(key, checked === true)
}
>
{t(`monitor.${key}`)}
</ContextMenuCheckboxItem>
))}
</ContextMenuContent>
</ContextMenu>
<tbody
className="relative block"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((row) => {
const cells = renderCells(items[row.index]);
return (
<ContextMenu key={row.key}>
<ContextMenuTrigger asChild>
<tr
{...rowProps(items[row.index])}
ref={virtualizer.measureElement}
data-index={row.index}
aria-rowindex={row.index + 2}
style={{
display: "grid",
gridTemplateColumns: template,
minHeight: 72,
position: "absolute",
top: 0,
left: 0,
width: tableWidth,
transform: `translateY(${row.start - scrollMargin}px)`,
}}
>
{visibleColumns.map(({ key }) => (
<React.Fragment key={key}>
{cells[key]}
</React.Fragment>
))}
</tr>
</ContextMenuTrigger>
<ContextMenuContent>
{renderMenu(items[row.index])}
</ContextMenuContent>
</ContextMenu>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
-129
View File
@@ -1,129 +0,0 @@
import React from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { Badge } from "../ui/badge";
export function MonitorHostLatency({
value,
responseValue,
}: {
value?: number | null;
responseValue?: number | null;
}) {
const { t } = useI18n();
const isValid = (n: number | null | undefined): n is number =>
typeof n === "number" && Number.isFinite(n) && n >= 0;
const usesResponse = !isValid(value) && isValid(responseValue);
const latency = usesResponse ? responseValue : value;
const known = isValid(latency);
const bars = !known ? 0 : latency <= 100 ? 4 : latency <= 300 ? 2 : 1;
const tone = !known
? "text-muted-foreground"
: bars === 4
? "text-emerald-600 dark:text-emerald-400"
: bars === 2
? "text-amber-500 dark:text-amber-400"
: "text-red-600 dark:text-red-400";
const label = `${t(usesResponse ? "monitor.responseLatency" : "monitor.latency")}: ${known ? `${Number(latency.toFixed(1))} ms` : "--"}`;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
aria-label={label}
className={`inline-flex h-5 shrink-0 items-center ${tone}`}
data-monitor-latency
data-monitor-signal-bars={bars}
>
<svg width="18" height="14" viewBox="0 0 18 14" aria-hidden="true">
{[4, 7, 10, 13].map((height, index) => (
<rect
key={height}
x={1 + index * 4}
y={14 - height}
width="3"
height={height}
rx="0.8"
fill="currentColor"
opacity={index < bars ? 1 : 0.18}
/>
))}
</svg>
</span>
</TooltipTrigger>
<TooltipContent className={tooltipClass}>{label}</TooltipContent>
</Tooltip>
);
}
const tooltipClass =
"max-w-sm whitespace-pre-wrap break-words bg-popover text-popover-foreground shadow-md ring-1 ring-border/40";
export function MonitorHostTags({ tags = [] }: { tags?: string[] }) {
if (!tags.length)
return (
<span className="inline-flex h-5 items-center text-[10px] text-muted-foreground">
--
</span>
);
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex h-5 min-w-0 max-w-full items-center gap-1 overflow-hidden"
data-monitor-tags
>
<Badge
variant="secondary"
className="inline-block max-w-20 truncate border-0 px-1.5 py-0 text-[10px] font-normal leading-5"
>
{tags[0]}
</Badge>
{tags.length > 1 && (
<span className="shrink-0 text-[10px] text-muted-foreground">
+{tags.length - 1}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent className={tooltipClass}>
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="max-w-full break-all text-[10px] font-normal"
>
{tag}
</Badge>
))}
</div>
</TooltipContent>
</Tooltip>
);
}
export function MonitorHostNotes({
notes,
focusable = false,
}: {
notes?: string;
focusable?: boolean;
}) {
if (!notes?.trim())
return <span className="text-[10px] text-muted-foreground">--</span>;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
data-monitor-notes
tabIndex={focusable ? 0 : undefined}
className="block min-w-0 truncate text-[10px] text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-primary"
>
{notes}
</span>
</TooltipTrigger>
<TooltipContent className={`${tooltipClass} max-h-64 overflow-y-auto`}>
{notes}
</TooltipContent>
</Tooltip>
);
}
+1 -21
View File
@@ -10,26 +10,6 @@ import type { Host } from "../../domain/models";
import type { MonitorPoint } from "../../domain/monitorRuntime";
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { MonitorHostLatency } from "./MonitorHostMetadata";
import { TooltipProvider } from "../ui/tooltip";
test("latency signals use the stats response when transport ping is unavailable and prefer valid ping readings", () => {
for (const [ping, response, bars, label] of [
[null, 145, "2", "Response latency (includes remote processing): 145 ms"],
[0, 500, "4", "Latency: 0 ms"],
[NaN, 450, "1", "Response latency (includes remote processing): 450 ms"],
[null, null, "0", "Latency: --"],
] as const) {
const document = new JSDOM(renderToStaticMarkup(
<I18nProvider locale="en"><TooltipProvider>
<MonitorHostLatency value={ping} responseValue={response} />
</TooltipProvider></I18nProvider>,
)).window.document;
const signal = document.querySelector("[data-monitor-latency]")!;
assert.equal(signal.getAttribute("data-monitor-signal-bars"), bars);
assert.equal(signal.getAttribute("aria-label"), label);
}
});
const now = 1_800_000_000_000;
const point = (ago: number, cpu: number | null): MonitorPoint => ({
@@ -181,7 +161,7 @@ for (const view of ["list", "charts"] as const) {
assert.equal(document.querySelector("header .lucide-gauge"), null);
assert.equal(document.querySelector(".lucide-activity"), null);
if (view === "list") {
const network = document.querySelector("[data-monitor-cell=network]")!;
const network = document.querySelector("tbody tr")!.children[5];
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
}
});
+259 -368
View File
@@ -6,7 +6,6 @@ import {
Copy,
Cpu,
Gauge,
Gpu,
HardDrive,
LayoutGrid,
List,
@@ -39,7 +38,6 @@ import type { Host } from "../../domain/models";
import { buildMonitorTrendGeometry, findMonitorChartPoint } from "../../domain/monitorChart";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { ContextMenuItem } from "../ui/context-menu";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea";
@@ -72,9 +70,6 @@ import { ResourceBar } from "../systemManager/ResourceBar";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass } from "./VaultPageHeader";
import { toast } from "../ui/toast";
import { TooltipProvider } from "../ui/tooltip";
import { MonitorHostCollection } from "./MonitorHostCollection";
import { MonitorHostLatency, MonitorHostNotes, MonitorHostTags } from "./MonitorHostMetadata";
const emptyMonitorPoints: MonitorPoint[] = [];
const percent = (n: number | null | undefined) =>
@@ -219,15 +214,22 @@ export function MonitorPanels({
} | null>(null);
const [selecting, setSelecting] = useState(false);
const [detail, setDetail] = useState<string | null>(null);
const [recovery, setRecovery] = useState<{ hostId: string; username: string; password: string } | null>(null);
const [deleting, setDeleting] = useState<MonitorPanel | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [login, setLogin] = useState({
hostId: "",
username: "",
password: "",
});
useEffect(
() => setLogin({ hostId: "", username: "", password: "" }),
[detail],
);
const panel = panels.find((p) => p.id === selected) ?? panels[0];
const running = !!panel && runtime.running.includes(panel.id);
const startEdit = (base: MonitorPanel | null) => {
setDetail(null);
setRecovery(null);
setSelecting(false);
setError(null);
setEditor({
@@ -303,39 +305,28 @@ export function MonitorPanels({
toast.error(t("monitor.saveFailed"));
}
};
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
);
const orderedHostIds = useMemo(() => {
if (!panel) return [];
return panel.sort === "manual"
? panel.hostIds
: [...panel.hostIds].sort((a, b) =>
(hostById.get(a)?.label ?? a).localeCompare(
hostById.get(b)?.label ?? b,
),
);
}, [panel, hostById]);
return panel.sort === "manual" ? panel.hostIds : [...panel.hostIds].sort((a, b) =>
(hosts.find((host) => host.id === a)?.label ?? a).localeCompare(hosts.find((host) => host.id === b)?.label ?? b),
);
}, [panel, hosts]);
const rows = useMemo(() => {
if (!panel) return [];
const q = search.trim().toLocaleLowerCase();
const rows = orderedHostIds.map((id) => ({
id,
host: hostById.get(id),
host: hosts.find((h) => h.id === id),
state: monitorStateSince(runtime.hosts[id], runtime.startedAt[panel.id]),
}));
return rows.filter(
({ host }) =>
!q ||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""} ${host?.tags.join(" ") ?? ""} ${host?.notes ?? ""}`
({ host }) => !q ||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""}`
.toLocaleLowerCase()
.includes(q),
);
}, [panel, orderedHostIds, hostById, runtime, search]);
const dragProps = (
hostId: string,
): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
}, [panel, orderedHostIds, hosts, runtime, search]);
const dragProps = (hostId: string): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
draggable: true,
"data-monitor-host": hostId,
onDragStart: (event) => {
@@ -345,12 +336,7 @@ export function MonitorPanels({
setDragging({ panelId: panel.id, hostId });
},
onDragOver: (event) => {
if (
!panel ||
dragging?.panelId !== panel.id ||
dragging.hostId === hostId
)
return;
if (!panel || dragging?.panelId !== panel.id || dragging.hostId === hostId) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDropTarget(hostId);
@@ -358,11 +344,10 @@ export function MonitorPanels({
onDrop: (event) => {
if (!panel || dragging?.panelId !== panel.id) return;
event.preventDefault();
if (dragging.hostId !== hostId)
void patchPanel({
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
sort: "manual",
});
if (dragging.hostId !== hostId) void patchPanel({
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
sort: "manual",
});
setDragging(null);
setDropTarget(null);
},
@@ -377,24 +362,18 @@ export function MonitorPanels({
: !running
? "stopped"
: state?.status === "live" &&
Date.now() - state.at >
Math.max(
5000,
(panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500,
)
Date.now() - state.at > Math.max(5000, (panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500)
? "stale"
: (state?.status ?? "queued");
const stateBadge = (host: Host | undefined, state?: MonitorHostState) => (
<span
className={cn(
"inline-flex min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
"inline-flex items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
status(host, state) === "live"
? "text-emerald-700 dark:text-emerald-400"
: ["error", "disconnected", "missing"].includes(status(host, state))
? "text-destructive"
: ["login", "stale", "unsupported", "limit"].includes(
status(host, state),
)
: ["login", "stale", "unsupported", "limit"].includes(status(host, state))
? "text-amber-700 dark:text-amber-400"
: "text-muted-foreground",
)}
@@ -403,9 +382,7 @@ export function MonitorPanels({
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full bg-current"
/>
<span className="truncate">
{t(`monitor.status.${status(host, state)}`)}
</span>
{t(`monitor.status.${status(host, state)}`)}
</span>
);
const renderMetrics = (
@@ -468,10 +445,6 @@ export function MonitorPanels({
};
const hostDetail = hosts.find((h) => h.id === detail);
const detailState = detail && panel ? monitorStateSince(runtime.hosts[detail], runtime.startedAt[panel.id]) : undefined;
const detailAvailable = status(hostDetail, detailState) === "live";
useEffect(() => {
if (detail && !detailAvailable) setDetail(null);
}, [detail, detailAvailable]);
return (
<section
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden"
@@ -674,76 +647,35 @@ export function MonitorPanels({
</SelectContent>
</Select>
</div>
{!rows.length ? (
<div className="flex-1 overflow-auto p-12 text-center text-sm text-muted-foreground">
{t("monitor.noMatches")}
</div>
) : (
<TooltipProvider delayDuration={180}>
<MonitorHostCollection
items={rows}
view={panel.view}
layoutKey={`${panel.id}:${panel.view}:${search}`}
pinnedId={
dragging?.panelId === panel.id ? dragging.hostId : undefined
}
renderMenu={({ id, host, state }) => (
<>
<ContextMenuItem disabled={!host} onSelect={() => host && onConnect(host)}>
{t("monitor.openTerminal")}
</ContextMenuItem>
{host && running && ["login", "error", "disconnected"].includes(status(host, state)) && (
<ContextMenuItem data-monitor-retry={id} onSelect={() => setRecovery({hostId: id, username: host.username ?? "", password: ""})}>
{t("monitor.retry")}
</ContextMenuItem>
)}
</>
)}
rowProps={({ id, host, state }) => ({
...dragProps(id),
className: cn(
"group cursor-default [&>td]:bg-card/70 hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td]:min-w-0 [&>td]:px-3 [&>td]:py-3 [&>td]:flex [&>td]:items-center",
dragging?.panelId === panel.id &&
dragging.hostId === id &&
"cursor-grabbing opacity-40",
dropTarget === id && "[&>td]:bg-accent",
status(host, state) !== "live" && "text-muted-foreground opacity-50",
),
})}
renderCard={({ id, host, state }) => (
<article
{...dragProps(id)}
className={cn(
"relative h-full min-w-0 overflow-hidden rounded-xl bg-card/70 shadow-sm shadow-black/[0.02] transition-colors hover:bg-card",
dragging?.panelId === panel.id &&
dragging.hostId === id &&
"cursor-grabbing opacity-40",
dropTarget === id && "bg-accent",
status(host, state) !== "live" && "opacity-50",
)}
>
<div className="min-h-0 min-w-0 flex-1 overflow-auto" data-section="vault-monitor-list">
<div className="p-4">
{!rows.length ? (
<p className="p-8 text-center text-sm text-muted-foreground">
{t("monitor.noMatches")}
</p>
) : panel.view === "charts" ? (
<div className="grid auto-rows-fr grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-4">
{rows.map(({ id, host, state }) => (
<button
type="button"
className="flex h-full w-full min-w-0 cursor-default flex-col p-4 text-left outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
aria-disabled={status(host, state) !== "live"}
key={id}
{...dragProps(id)}
className={cn(
"flex h-full min-w-0 cursor-default flex-col overflow-hidden rounded-xl bg-card/70 p-4 text-left shadow-sm shadow-black/[0.02] transition-colors hover:bg-card focus-visible:outline focus-visible:outline-primary",
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
dropTarget === id && "bg-accent",
status(host, state) !== "live" &&
"text-muted-foreground",
)}
onClick={() => {
if (status(host, state) !== "live") return;
setEditor(null);
setDetail(id);
}}
>
<div className="mb-2 flex w-full min-w-0 items-center gap-3 pr-7">
<div className="mb-5 flex items-center gap-3">
{host ? (
<DistroAvatar
host={host}
fallback={host.label}
size="log"
/>
<DistroAvatar host={host} fallback={host.label} size="log" />
) : (
<Server
size={24}
className="shrink-0 text-muted-foreground"
/>
<Server size={24} className="shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-foreground">
@@ -753,39 +685,20 @@ export function MonitorPanels({
{host?.hostname ?? id}
</p>
</div>
{stateBadge(host, state)}
</div>
<div className="mb-2 flex h-5 w-full min-w-0 items-center gap-2">
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
<div className="min-w-0 flex-1">
<MonitorHostTags tags={host?.tags} />
</div>
<div className="min-w-0 max-w-[40%]">
{stateBadge(host, state)}
</div>
</div>
<div
className={cn(
"w-full min-w-0",
status(host, state) !== "live" && "opacity-60",
)}
>
<div className={cn(status(host, state) !== "live" && "opacity-60")}>
{renderMetrics(state)}
<div className="mt-3">
<div className="mt-4">
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<HardDrive
size={13}
className="text-amber-600 dark:text-amber-400"
/>
<HardDrive size={13} className="text-amber-600 dark:text-amber-400" />
<span>{t("monitor.disks")}</span>
</div>
<MonitorDiskUsage stats={state?.stats} limit={2} />
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
<span className="flex items-center gap-1.5 py-1">
<ArrowDown
size={13}
className="shrink-0 text-sky-600 dark:text-sky-400"
/>
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
{rate(
state?.stats?.networkReady
? state.stats.netRxSpeed
@@ -793,10 +706,7 @@ export function MonitorPanels({
)}
</span>
<span className="flex items-center gap-1.5 py-1">
<ArrowUp
size={13}
className="shrink-0 text-violet-600 dark:text-violet-400"
/>
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
{rate(
state?.stats?.networkReady
? state.stats.netTxSpeed
@@ -805,169 +715,127 @@ export function MonitorPanels({
</span>
</div>
</div>
<div className="mt-auto flex w-full min-w-0 items-center gap-2 pt-2 text-[10px] text-muted-foreground">
<div className="min-w-0 flex-1">
<MonitorHostNotes notes={host?.notes} />
</div>
<span className="inline-flex shrink-0 items-center gap-1">
<Clock3 size={11} />
{state?.at
? new Date(state.at).toLocaleTimeString()
: t("monitor.noSamples")}
</span>
</div>
<p className="mt-auto flex items-center gap-1.5 pt-4 text-[10px] text-muted-foreground">
<Clock3 size={11} />
{state?.at
? t("monitor.updatedAt", {
time: new Date(state.at).toLocaleTimeString(),
})
: t("monitor.noSamples")}
</p>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-3 top-3 h-7 w-7 text-muted-foreground"
data-monitor-terminal={id}
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
title={t("monitor.openTerminal")}
disabled={!host}
onClick={(event) => {
event.stopPropagation();
if (host) onConnect(host);
}}
>
<Terminal size={14} />
</Button>
</article>
)}
renderCells={({ id, host, state }) => ({
host: (<td>
<button
type="button"
className="flex w-full min-w-0 items-center gap-2.5 text-left aria-disabled:no-underline hover:underline"
aria-disabled={status(host, state) !== "live"}
onClick={() => {
if (status(host, state) !== "live") return;
setEditor(null);
setDetail(id);
}}
>
{host ? (
<DistroAvatar
host={host}
fallback={host.label}
size="md"
/>
) : (
<Server
size={24}
className="shrink-0 text-muted-foreground"
/>
)}
<div className="min-w-0">
<div className="truncate font-medium">
{host?.label ?? t("monitor.missingHost")}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{host?.hostname ?? id}
</div>
</div>
</button>
</td>),
latency: (<td>
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
</td>),
tags: (<td>
<MonitorHostTags tags={host?.tags} />
</td>),
notes: (<td>
<div className="min-w-0 w-full">
<MonitorHostNotes notes={host?.notes} focusable />
</div>
</td>),
cpu: (<td className="tabular-nums">
<ResourceBar
className="w-full"
label=""
value={state?.stats?.cpu}
/>
</td>),
memory: (<td className="tabular-nums">
<ResourceBar
className="w-full"
label=""
value={
state?.stats?.memTotal &&
state.stats.memUsed != null
? (state.stats.memUsed / state.stats.memTotal) *
100
: null
}
/>
</td>),
disks: (<td>
<div className="w-full min-w-0">
<MonitorDiskUsage stats={state?.stats} />
</div>
</td>),
network: (<td
data-monitor-cell="network"
className="tabular-nums whitespace-nowrap"
>
<div>
<div className="flex items-center gap-1.5">
<ArrowDown
size={13}
className="shrink-0 text-sky-600 dark:text-sky-400"
/>
<span className="sr-only">
{t("monitor.download")}:{" "}
</span>
{rate(
state?.stats?.networkReady
? state.stats.netRxSpeed
: null,
)}
</div>
<div className="flex items-center gap-1.5">
<ArrowUp
size={13}
className="shrink-0 text-violet-600 dark:text-violet-400"
/>
<span className="sr-only">
{t("monitor.upload")}:{" "}
</span>
{rate(
state?.stats?.networkReady
? state.stats.netTxSpeed
: null,
)}
</div>
</div>
</td>),
updated: (<td className="whitespace-nowrap tabular-nums">
{state?.at
? new Date(state.at).toLocaleTimeString()
: "--"}
</td>),
status: (<td>{stateBadge(host, state)}</td>),
terminal: (<td className="sticky right-0 z-10 justify-center !bg-background before:pointer-events-none before:absolute before:inset-0 before:rounded-r-xl before:bg-card/70 group-hover:before:bg-card">
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-7 w-7 text-muted-foreground"
data-monitor-terminal={id}
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
title={t("monitor.openTerminal")}
disabled={!host}
onClick={(event) => {
event.stopPropagation();
if (host) onConnect(host);
}}
>
<Terminal size={14} />
</Button>
</td>)
})}
/>
</TooltipProvider>
)}
))}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[840px] border-separate border-spacing-x-0 border-spacing-y-2 text-xs">
<thead className="text-muted-foreground">
<tr>
{[
"host",
"status",
"cpu",
"memory",
"disks",
"network",
"updated",
].map((k) => (
<th
key={k}
className="px-3 py-3 text-left font-medium"
>
{t(`monitor.${k}`)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(({ id, host, state }) => {
const s = state?.stats;
return (
<tr
key={id}
{...dragProps(id)}
className={cn(
"cursor-default [&>td]:bg-card/70 [&>td]:transition-colors hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td:last-child]:rounded-r-xl",
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
dropTarget === id && "[&>td]:bg-accent",
status(host, state) !== "live" &&
"text-muted-foreground",
)}
>
<td className="px-3 py-3">
<button
draggable
className="flex cursor-inherit items-center gap-2.5 text-left hover:underline"
onClick={() => {
setEditor(null);
setDetail(id);
}}
>
{host ? (
<DistroAvatar host={host} fallback={host.label} size="md" />
) : (
<Server size={24} className="shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 max-w-52">
<div className="truncate font-medium">
{host?.label ?? t("monitor.missingHost")}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{host?.hostname ?? id}
</div>
</div>
</button>
</td>
<td className="px-3">
{stateBadge(host, state)}
</td>
<td className="px-3 tabular-nums">
<ResourceBar label="" value={s?.cpu} />
</td>
<td className="px-3 tabular-nums">
<ResourceBar
label=""
value={
s?.memTotal && s.memUsed != null
? (s.memUsed / s.memTotal) * 100
: null
}
/>
</td>
<td className="min-w-[220px] px-3 py-3">
<MonitorDiskUsage stats={s} />
</td>
<td className="px-3 tabular-nums whitespace-nowrap">
<div className="flex items-center gap-1.5">
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
<span className="sr-only">{t("monitor.download")}: </span>
{rate(s?.networkReady ? s.netRxSpeed : null)}
</div>
<div className="flex items-center gap-1.5">
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
<span className="sr-only">{t("monitor.upload")}: </span>
{rate(s?.networkReady ? s.netTxSpeed : null)}
</div>
</td>
<td className="px-3 whitespace-nowrap">
{state?.at
? new Date(state.at).toLocaleTimeString()
: "--"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<p className="pt-4 text-[11px] leading-relaxed text-muted-foreground">
{t("monitor.memoryOnlyHint")}
</p>
</div>
</div>
</>
)}
</div>
@@ -1138,9 +1006,8 @@ export function MonitorPanels({
className={monitorAsideClass}
/>
)}
{detail && detailAvailable && (
{detail && (
<AsidePanel
dataSection="vault-monitor-detail"
open
onClose={() => setDetail(null)}
title={hostDetail?.label ?? t("monitor.missingHost")}
@@ -1152,7 +1019,7 @@ export function MonitorPanels({
width="w-[380px]"
className={monitorAsideClass}
>
<AsidePanelContent scrollMode="native">
<AsidePanelContent>
<div className="flex items-center gap-3">
{hostDetail && <DistroAvatar host={hostDetail} fallback={hostDetail.label} size="lg" />}
<div className="min-w-0 flex-1">
@@ -1160,41 +1027,73 @@ export function MonitorPanels({
{stateBadge(hostDetail, detailState)}
</div>
</div>
{renderMetrics(detailState, true)}
{detailState?.stats && (
detailState.stats.gpu != null ||
detailState.stats.gpuName ||
(detailState.stats.gpuMemTotal ?? 0) > 0
{["login", "error", "disconnected"].includes(
detailState?.status ?? "",
) && (
<section className="space-y-3" data-monitor-gpu>
<h3 className="flex items-center gap-1.5 text-sm font-medium">
<Gpu size={15} className="text-muted-foreground" />
{t("systemManager.tabs.gpu")}
</h3>
{detailState.stats.gpuName && (
<p className="break-words text-xs text-muted-foreground">
{detailState.stats.gpuName}
</p>
)}
<div className="space-y-1.5">
<p className="text-[11px] text-muted-foreground">{t("systemManager.gpu.util")}</p>
<ResourceBar label="" value={detailState.stats.gpu} />
<>
<p className="text-xs text-muted-foreground">
{t("monitor.loginHint")}
</p>
<div className="space-y-2">
<Label htmlFor="monitor-login-user">
{t("monitor.username")}
</Label>
<Input
id="monitor-login-user"
autoComplete="off"
value={
login.hostId === detail
? login.username
: (hostDetail?.username ?? "")
}
onChange={(e) =>
setLogin((prev) => ({
hostId: detail,
username: e.target.value,
password: prev.hostId === detail ? prev.password : "",
}))
}
/>
<Label htmlFor="monitor-login-password">
{t("monitor.password")}
</Label>
<Input
id="monitor-login-password"
type="password"
autoComplete="new-password"
value={login.hostId === detail ? login.password : ""}
onChange={(e) =>
setLogin((prev) => ({
hostId: detail,
username:
prev.hostId === detail
? prev.username
: (hostDetail?.username ?? ""),
password: e.target.value,
}))
}
/>
</div>
<div className="space-y-1.5">
<div className="flex justify-between gap-2 text-[11px] text-muted-foreground">
<span>{t("systemManager.gpu.memory")}</span>
<span className="tabular-nums">
{capacity(detailState.stats.gpuMemUsed)} / {capacity(detailState.stats.gpuMemTotal)}
</span>
</div>
<ResourceBar label="" value={
detailState.stats.gpuMemUsed != null && (detailState.stats.gpuMemTotal ?? 0) > 0
? detailState.stats.gpuMemUsed / detailState.stats.gpuMemTotal! * 100
: null
} />
</div>
</section>
<Button
variant="outline"
onClick={() => {
monitorActions.retry(
detail,
login.hostId === detail && login.password
? {
username: login.username,
password: login.password,
}
: undefined,
);
setLogin({ hostId: "", username: "", password: "" });
}}
>
{t("monitor.retry")}
</Button>
</>
)}
{renderMetrics(detailState, true)}
<p className="text-xs text-muted-foreground">
{t("monitor.observed", {
count: detailState?.points.length
@@ -1289,6 +1188,24 @@ export function MonitorPanels({
<p>{detailState?.stats?.kernelRelease ?? "--"}</p>
<p>{detailState?.stats?.hostname ?? "--"}</p>
</div>
{detailState?.status === "unsupported" && (
<p className="text-xs text-muted-foreground">
{t("monitor.unsupportedHint")}
</p>
)}
{!hostDetail && panel && (
<Button
variant="outline"
onClick={() => {
void patchPanel({
hostIds: panel.hostIds.filter((id) => id !== detail),
});
setDetail(null);
}}
>
{t("monitor.removeHost")}
</Button>
)}
</AsidePanelContent>
<AsidePanelFooter>
<Button
@@ -1302,32 +1219,6 @@ export function MonitorPanels({
</AsidePanelFooter>
</AsidePanel>
)}
<Dialog open={!!recovery} onOpenChange={(open) => !open && setRecovery(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("monitor.retry")}</DialogTitle>
<DialogDescription>{t("monitor.loginHint")}</DialogDescription>
</DialogHeader>
<form onSubmit={(event) => {
event.preventDefault();
if (!recovery) return;
monitorActions.retry(recovery.hostId, recovery.password ? {username: recovery.username, password: recovery.password} : undefined);
setRecovery(null);
}} className="space-y-4">
<p className="truncate text-sm font-medium">{hostById.get(recovery?.hostId ?? "")?.label}</p>
<div className="space-y-2">
<Label htmlFor="monitor-login-user">{t("monitor.username")}</Label>
<Input id="monitor-login-user" autoComplete="off" value={recovery?.username ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, username: event.target.value})} />
<Label htmlFor="monitor-login-password">{t("monitor.password")}</Label>
<Input id="monitor-login-password" type="password" autoComplete="new-password" value={recovery?.password ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, password: event.target.value})} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRecovery(null)}>{t("common.cancel")}</Button>
<Button type="submit">{t("monitor.retry")}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={!!deleting}
onOpenChange={(open) => !open && setDeleting(null)}
-65
View File
@@ -1,65 +0,0 @@
import type { BrowserBookmark } from './browserBookmark';
export class HostBrowserIconError extends Error {
constructor(public readonly reason: 'file-type' | 'file-size' | 'decode') {
super(reason);
this.name = 'HostBrowserIconError';
}
}
export interface HostBrowserTab {
id: string;
url: string;
title: string;
loading: boolean;
canGoBack: boolean;
canGoForward: boolean;
iconDataUrl: string | null;
iconGlyph: string | null;
iconColor: string | null;
}
export interface HostBrowserState {
hostId: string;
label: string;
route: 'connecting' | 'connected' | 'disconnected';
error: string | null;
activeTabId: string | null;
tabs: HostBrowserTab[];
}
export interface HostBrowserDownload {
id: string;
hostId: string;
filename: string;
url: string;
savePath: string;
status: 'waiting' | 'progressing' | 'completed' | 'cancelled' | 'interrupted';
received: number;
total: number;
startedAt: number;
endedAt: number | null;
missing: boolean;
}
export type HostBrowserBookmark = BrowserBookmark & {
siteIconDataUrl?: string | null;
siteIconGlyph?: string | null;
siteIconColor?: string | null;
};
export type HostBrowserCommand =
| { type: 'new-tab'; url?: string }
| { type: 'navigate'; url: string; tabId?: string }
| { type: 'activate' | 'close-tab' | 'back' | 'forward' | 'reload' | 'stop' | 'reset-icon'; tabId?: string }
| { type: 'reorder'; tabId: string; beforeTabId: string }
| { type: 'set-icon'; tabId: string; dataUrl?: string | null; glyph: string; color: string }
| { type: 'retry' };
export type HostBrowserShortcut = { type: 'focus-address' | 'new-tab' | 'close-tab' | 'reload'; tabId: string };
export type HostBrowserPanelEvent = { browserId: string } & (
| { type: 'state'; state: HostBrowserState }
| { type: 'downloads'; downloads: HostBrowserDownload[]; startedId?: string }
| { type: 'bookmarks' }
| { type: 'shortcut'; shortcut: HostBrowserShortcut }
);
-2
View File
@@ -22,8 +22,6 @@ export type MonitorHostState = {
| "disconnected"
| "limit";
at: number;
/** GPU publication can advance independently of the primary sample/history. */
statsAt?: number;
/** Omitted by incremental replies when the receiver already has this sample. */
stats?: MonitorStats | null;
points: MonitorPoint[];
-63
View File
@@ -1,63 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
MONITOR_COLUMNS,
clampMonitorColumnWidth,
normalizeMonitorColumnWidths,
normalizeMonitorHiddenColumns,
monitorGridColumns,
} from "./monitorTableLayout";
test("saved column sizes cannot hide data or create unbounded table widths", () => {
const widths = normalizeMonitorColumnWidths({
host: -1,
latency: Infinity,
notes: 100000,
terminal: NaN,
unknown: 400,
});
assert.equal(widths.host, 160);
assert.equal(widths.latency, 90);
assert.equal(widths.notes, 640);
assert.equal(widths.terminal, 56);
assert.equal(Object.keys(widths).length, MONITOR_COLUMNS.length);
assert.deepEqual(
normalizeMonitorColumnWidths(null),
normalizeMonitorColumnWidths({}),
);
for (const column of MONITOR_COLUMNS) {
assert.equal(clampMonitorColumnWidth(column.key, -100), column.min);
assert.equal(
clampMonitorColumnWidth(column.key, 10000),
column.key === "terminal" ? 200 : 640,
);
}
});
test("virtual card rows adapt to narrow panes and do not allocate empty columns", () => {
assert.equal(monitorGridColumns(200, 1000), 1);
assert.equal(monitorGridColumns(575, 1000), 1);
assert.equal(monitorGridColumns(576, 1000), 2);
assert.equal(monitorGridColumns(1248, 1000), 4);
assert.equal(monitorGridColumns(1248, 2), 2);
assert.equal(monitorGridColumns(0, 1000), 1);
});
test("hidden columns always preserve host and terminal and discard unknown settings", () => {
assert.deepEqual(
normalizeMonitorHiddenColumns([
"host",
"terminal",
"cpu",
"cpu",
"unknown",
"status",
]),
["cpu", "status"],
);
assert.deepEqual(normalizeMonitorHiddenColumns(null), []);
assert.deepEqual(normalizeMonitorHiddenColumns({ cpu: true }), []);
assert.equal(
normalizeMonitorHiddenColumns(MONITOR_COLUMNS.map((c) => c.key)).length,
MONITOR_COLUMNS.length - 2,
);
});
-64
View File
@@ -1,64 +0,0 @@
export const MONITOR_COLUMNS = [
{ key: "host", width: 220, min: 160 },
{ key: "latency", width: 90, min: 80 },
{ key: "tags", width: 150, min: 90 },
{ key: "notes", width: 180, min: 110 },
{ key: "cpu", width: 120, min: 120 },
{ key: "memory", width: 120, min: 120 },
{ key: "disks", width: 240, min: 220 },
{ key: "network", width: 150, min: 140 },
{ key: "updated", width: 110, min: 95 },
{ key: "status", width: 100, min: 90 },
{ key: "terminal", width: 56, min: 56 },
] as const;
export type MonitorColumn = (typeof MONITOR_COLUMNS)[number]["key"];
export type MonitorColumnWidths = Record<MonitorColumn, number>;
export const MONITOR_CARD_HEIGHT = 368;
export function clampMonitorColumnWidth(
key: MonitorColumn,
width: number,
): number {
const column = MONITOR_COLUMNS.find((column) => column.key === key)!;
return Number.isFinite(width)
? Math.round(
Math.max(column.min, Math.min(key === "terminal" ? 200 : 640, width)),
)
: column.width;
}
export function normalizeMonitorColumnWidths(
value: unknown,
): MonitorColumnWidths {
const stored =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
return Object.fromEntries(
MONITOR_COLUMNS.map((column) => [
column.key,
clampMonitorColumnWidth(
column.key,
typeof stored[column.key] === "number"
? (stored[column.key] as number)
: column.width,
),
]),
) as MonitorColumnWidths;
}
export function monitorGridColumns(width: number, count: number): number {
return Math.max(
1,
Math.min(count, Math.floor((Math.max(0, width) + 16) / 296)),
);
}
/** Only optional columns can be hidden, including when restoring old or corrupt settings. */
export function normalizeMonitorHiddenColumns(value: unknown): MonitorColumn[] {
return MONITOR_COLUMNS.filter(
({ key }) =>
key !== "host" &&
key !== "terminal" &&
Array.isArray(value) &&
value.includes(key),
).map(({ key }) => key);
}
+1 -2
View File
@@ -352,8 +352,7 @@ function createMonitorService({
historyId,
status: entry?.status ?? host.status ?? "limit",
at: shared?.at ?? 0,
statsAt: shared?.statsAt ?? shared?.at ?? 0,
...(append && last?.historyId === historyId && last.statsAt === (shared?.statsAt ?? shared?.at ?? 0)
...(append && last?.historyId === historyId && last.statsAt === (shared?.at ?? 0)
? {}
: { stats: shared?.result?.success ? shared.result.stats : null }),
points: append && last?.historyId === historyId
+1 -21
View File
@@ -280,7 +280,7 @@ test("full terminal readers upgrade a lightweight cached sample and keep subsequ
memTotal: 100,
memUsed: null,
topProcesses: ctx._monitorStats ? [] : [{ name: "full-process" }],
gpu: 75,
gpu: ctx._monitorStats ? null : 75,
},
};
};
@@ -752,23 +752,3 @@ test("snapshot deltas send new points only, even with 256 full retained historie
assert.ok(Object.entries(fresh.hosts).every(([id, host]) => id === "h0" || !Object.hasOwn(host, "stats")));
f.service.dispose();
});
test('a completed GPU probe reaches incremental snapshots before the next CPU sample',async()=>{
const {cacheFullGpuStats}=require('./sshBridge/monitorGpuStats.cjs');
const f=fixture();
try {
f.service.reconcile(event(1),[{...demand(),interval:60}]);
await until(()=>f.service.snapshot(event(1)).hosts.h?.status==='live');
const initial=f.service.snapshot(event(1));
const host=initial.hosts.h;
const cursor={runtimeId:initial.runtimeId,epoch:initial.epoch,after:{h:{at:host.points.at(-1)?.at??0,statsAt:host.statsAt,historyId:host.historyId}}};
cacheFullGpuStats(f.c,{gpu:75,gpuName:'NVIDIA independent',gpuMemUsed:512,gpuMemTotal:8192});
const next=f.service.snapshot(event(1),cursor).hosts.h;
assert.equal(next.stats.gpu,75,'GPU data must not be omitted by the primary sample cursor');
assert.equal(next.at,host.at,'GPU publication must not change CPU freshness');
assert.deepEqual(next.points,[],'GPU publication must not add trend samples');
assert.ok(next.statsAt>host.statsAt);
const caughtUp={...cursor,after:{h:{...cursor.after.h,statsAt:next.statsAt}}};
assert.equal(f.service.snapshot(event(1),caughtUp).hosts.h.stats,undefined,'unchanged GPU data is not repeatedly sent');
} finally {f.service.dispose();}
});
@@ -91,54 +91,6 @@ test("worker IPC installs one owner teardown across all monitor handlers", async
]);
});
test("starting monitoring publishes latency even when SSH keepalives are disabled", async () => {
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
const { selectServerStatsFixtureOutput } = require("./sshBridge/serverStatsTestHelpers.cjs");
const handlers = register();
const e = event(93);
const options = {
hostname: "latency-fixture",
username: "fixture",
password: "fixture-only",
keepaliveInterval: 0,
verifyHostKeys: false,
};
const conn = new EventEmitter();
conn._sock = { destroyed: false };
conn.config = { keepaliveInterval: 0 };
conn.end = () => {};
conn.exec = (command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.write = () => true;
callback(null, stream);
setImmediate(() => {
stream.emit("data", Buffer.from(selectServerStatsFixtureOutput(command,
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:|NET:")));
stream.emit("close", 0);
});
};
const transport = pool.createTransport({ conn, endpoint: pool.buildConnectionReuseEndpoint(options) });
const holder = {};
pool.borrowTransport(transport, { kind: pool.LEASE_KINDS.shell, holder });
try {
await handlers.get("netcatty:monitor:update")(e, [{ id: "latency", hosts: [{ id: "host", options }] }]);
let snapshot;
const deadline = Date.now() + 2000;
do {
await tick();
snapshot = handlers.get("netcatty:monitor:snapshot")(e);
} while (snapshot.hosts.host?.status !== "live" && Date.now() < deadline);
assert.equal(snapshot.hosts.host?.status, "live");
assert.equal(snapshot.hosts.host.stats.memTotal, 8000);
assert.equal(snapshot.hosts.host.stats.latencyMs, null, "keepalive opt-out is preserved");
assert.equal(typeof snapshot.hosts.host.stats.responseLatencyMs, "number");
} finally {
bridge.stopAllMonitoring();
pool.returnTransport(holder);
}
});
test("worker receiving handlers accept the actual IPC sender without WebContents lifecycle methods", async () => {
const { createWorkerSender } = require("../terminalWorker/process.cjs");
@@ -1,120 +0,0 @@
"use strict";
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const GPU_ASSIGNMENT = `gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`;
const EMPTY_GPU = {
gpu: null,
gpuName: null,
gpuMemUsed: null,
gpuMemTotal: null,
};
const states = new WeakMap();
function stateFor(conn) {
let state = states.get(conn);
if (!state) {
state = {
stats: EMPTY_GPU,
nextAt: 0,
pending: null,
controller: null,
updatedAt: 0,
disabled: false,
};
states.set(conn, state);
}
return state;
}
function updateGpuStats(state, next) {
if (Object.keys(EMPTY_GPU).some((key) => state.stats[key] !== next[key])) {
// Publication time is separate from CPU history and strictly advances even
// when a fast GPU reply shares the CPU sample's millisecond.
state.updatedAt = Math.max(Date.now() + 1, state.updatedAt + 1);
}
state.stats = next;
}
function monitorGpuUpdatedAt(conn) {
return states.get(conn)?.updatedAt ?? 0;
}
function parseGpuStats(output) {
const raw =
String(output || "")
.split("|")
.find((part) => part.startsWith("GPU:"))
?.slice(4)
.trim()
.split(/\s+/) ?? [];
const utilization = parseInt(raw[0], 10);
if (!Number.isFinite(utilization)) return EMPTY_GPU;
const number = (value) =>
Number.isFinite(parseInt(value, 10)) ? parseInt(value, 10) : null;
return {
gpu: Math.max(0, Math.min(100, utilization)),
gpuName: raw.slice(3).join(" ") || null,
gpuMemUsed: number(raw[1]),
gpuMemTotal: number(raw[2]),
};
}
function withCachedMonitorGpu(conn, result) {
const state = states.get(conn);
return state && result?.success
? { ...result, stats: { ...result.stats, ...state.stats } }
: result;
}
function cacheFullGpuStats(conn, stats) {
const state = stateFor(conn);
state.controller?.abort();
updateGpuStats(
state,
Object.fromEntries(
Object.keys(EMPTY_GPU).map((key) => [key, stats[key] ?? null]),
),
);
state.nextAt = Date.now() + 10000;
}
/** Optional GPU work never joins the CPU/memory sampler or its history clock. */
function startMonitorGpuProbe(conn, command, options = {}) {
const state = stateFor(conn);
if (state.disabled || state.pending || Date.now() < state.nextAt) return;
const controller = new AbortController();
state.controller = controller;
state.nextAt = Date.now() + 30000;
state.pending = Promise.resolve()
.then(() =>
executeBoundedSshCommand(conn, command, {
...options,
signal: controller.signal,
invalidateTransportOnAbort: false,
invalidateOnOpenTimeout: false,
openingTimeoutMs: 2500,
runTimeoutMs: 2500,
maxOutputBytes: 65536,
}),
)
.then(({ stdout }) => {
if (controller.signal.aborted) return;
updateGpuStats(state, parseGpuStats(stdout));
state.nextAt = Date.now() + (state.stats.gpu == null ? 30000 : 10000);
})
.catch((error) => {
// A timed-out channel open cannot be withdrawn from ssh2's request FIFO.
// Keep this optional probe from disconnecting SSH or piling up requests.
if (error?.code === "SSH_EXEC_OPEN_TIMEOUT") state.disabled = true;
if (!controller.signal.aborted) updateGpuStats(state, EMPTY_GPU);
})
.finally(() => {
state.pending = null;
if (state.controller === controller) state.controller = null;
});
}
function cancelMonitorGpuProbe(conn) {
states.get(conn)?.controller?.abort();
}
module.exports = {
GPU_ASSIGNMENT,
monitorGpuUpdatedAt,
parseGpuStats,
withCachedMonitorGpu,
cacheFullGpuStats,
startMonitorGpuProbe,
cancelMonitorGpuProbe,
};
@@ -1,166 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const {
startMonitorGpuProbe,
withCachedMonitorGpu,
cacheFullGpuStats,
cancelMonitorGpuProbe,
} = require("./monitorGpuStats.cjs");
const tick = () => new Promise((resolve) => setImmediate(resolve));
function stream() {
const s = new EventEmitter();
s.stderr = new EventEmitter();
s.close = () => {};
s.destroy = () => {};
return s;
}
const sample = { success: true, stats: { cpu: 12, memTotal: 4096, gpu: null } };
test("GPU work is coalesced, cached independently, and never changes CPU readings", async (t) => {
let now = 100000,
calls = 0,
current;
t.mock.method(Date, "now", () => now);
const conn = {
exec(_command, cb) {
calls++;
current = stream();
cb(null, current);
},
};
t.after(() => cancelMonitorGpuProbe(conn));
startMonitorGpuProbe(conn, "gpu");
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 1);
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
current.emit("data", Buffer.from("GPU:75 1024 8192 NVIDIA T4"));
current.emit("close", 0);
await tick();
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 75);
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpuName, "NVIDIA T4");
now += 9999;
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 1);
now += 1;
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 2);
current.emit("close", 0);
await tick();
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
now += 29000;
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 2, "missing GPU probes back off");
});
test("cancelling optional GPU work preserves a full reader cache and ignores late results", async (t) => {
let current;
const conn = {
exec(_command, cb) {
current = stream();
cb(null, current);
},
};
startMonitorGpuProbe(conn, "gpu");
await tick();
cacheFullGpuStats(conn, {
gpu: 42,
gpuName: "NVIDIA full",
gpuMemUsed: 512,
gpuMemTotal: 8192,
});
current.emit("data", Buffer.from("GPU:99 1 2 NVIDIA late"));
current.emit("close", 0);
await tick();
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 42);
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
cancelMonitorGpuProbe(conn);
t.after(() => cancelMonitorGpuProbe(conn));
});
test("monitor snapshots include cached GPU without adding history samples or changing sample time", async () => {
const { sampleSharedStats, readSharedStats } = require("./sharedStats.cjs");
const conn = {};
await sampleSharedStats({ conn, _monitorStats: true }, async () => sample);
const before = readSharedStats(conn);
cacheFullGpuStats(conn, {
gpu: 42,
gpuName: "NVIDIA cached",
gpuMemUsed: 1,
gpuMemTotal: 2,
});
const after = readSharedStats(conn);
assert.equal(after.result.stats.gpu, 42);
assert.equal(after.result.stats.cpu, 12);
assert.equal(after.at, before.at);
assert.deepEqual(after.points, before.points);
});
test("stopping GPU work before channel-open closes the late channel without disconnecting SSH", async () => {
let callback,
closed = 0,
disconnected = 0;
const conn = {
exec(_command, cb) {
callback = cb;
},
end() {
disconnected++;
},
};
startMonitorGpuProbe(conn, "gpu");
await tick();
require("./sharedStats.cjs").cancelSharedStats(conn);
const late = stream();
late.close = () => closed++;
callback(null, late);
await tick();
assert.ok(closed > 0);
assert.equal(disconnected, 0);
});
test("optional GPU channel-open timeout neither disconnects SSH nor stacks repeated open requests", async () => {
let callback,
fireTimer,
calls = 0,
disconnected = 0,
closed = 0;
const conn = {
exec(_command, cb) {
calls++;
callback = cb;
},
end() {
disconnected++;
},
destroy() {
disconnected++;
},
};
startMonitorGpuProbe(conn, "gpu", {
setTimeoutFn: (fn) => {
fireTimer = fn;
return 1;
},
clearTimeoutFn: () => {},
});
await tick();
fireTimer();
await tick();
assert.equal(
disconnected,
0,
"optional channel timeout must leave the shared transport intact",
);
startMonitorGpuProbe(conn, "gpu");
await tick();
assert.equal(calls, 1, "one unresolved GPU open per transport at most");
const late = stream();
late.close = () => closed++;
callback(null, late);
await tick();
assert.ok(closed > 0);
});
+25 -30
View File
@@ -1,6 +1,5 @@
/* eslint-disable no-undef */
const { sampleSharedStats } = require("./sharedStats.cjs");
const { GPU_ASSIGNMENT, parseGpuStats, withCachedMonitorGpu, cacheFullGpuStats, startMonitorGpuProbe } = require("./monitorGpuStats.cjs");
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
function extraExecUnsupportedError(session) {
@@ -1067,18 +1066,7 @@ function createSessionOpsApi(ctx) {
}
async function getStatsForContext(session, event, sessionId) {
const result = await sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
const conn = session.conn || session.moshStatsConn || session.etStatsConn;
if (!conn || !result.success || !result.stats) return result;
if (!session._monitorStats) {
cacheFullGpuStats(conn, result.stats);
return result;
}
if (!session.singleChannelSsh) {
const command = withPosixRemoteWatchdog(`if [ "$(uname -s 2>/dev/null)" = "Linux" ]; then ${GPU_ASSIGNMENT}; echo "GPU:$gpustat"; fi`, 2500);
startMonitorGpuProbe(conn, command, {setTimeoutFn:setTimeout,clearTimeoutFn:clearTimeout});
}
return withCachedMonitorGpu(conn, result);
return sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
}
async function collectServerStats(session, event, sessionId) {
@@ -1211,7 +1199,7 @@ function createSessionOpsApi(ctx) {
`uptime=$(awk -v n="$nowsec" -v b="$bootsec" 'BEGIN{if(n>0&&b>0)printf "%.0f",n-b}')`,
`loadavg=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}' | awk '{print $1" "$2" "$3"}')`,
`echo "CPU:$cpupct|CORES:$cores|MEMINFO:$memtotal $memfree 0 $memcached $swaptotal $swapfree|PROCS:$procs|DISKS:$disks|NET:$net|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`,
].filter(command => !session._monitorStats || !command.startsWith('procs=')).join('; ');
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
// Prefer df's filesystem type column so FUSE mounts can be identified
// even when their source name looks like an ordinary remote path.
@@ -1314,7 +1302,7 @@ function createSessionOpsApi(ctx) {
// Bound GPU queries independently so a stuck driver cannot stop CPU/memory
// polling. Hosts without timeout also omit this optional metric.
// macOS is skipped: powermetrics needs root, which stats must not.
GPU_ASSIGNMENT,
`gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`,
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
@@ -1376,12 +1364,6 @@ function createSessionOpsApi(ctx) {
const pingLatencyPromise = sshStatsConn && !pingDisabled && typeof measureSshPingLatency === 'function'
? Promise.resolve(measureSshPingLatency(sshStatsConn)).catch(() => null)
: Promise.resolve(null);
// Observe the exec acknowledgement already needed for this sample. This
// remains available when global-request keepalives are disabled, without
// sending another ping or opening an additional channel/connection.
// Keep it separate from the transport RTT: channel setup also includes
// remote processing, and shared terminal readers must retain that distinction.
let responseLatencyMs = null;
const formatStatsError = (error) => (
error?.code === "SSH_EXEC_OPEN_TIMEOUT" || error?.code === "SSH_EXEC_RUN_TIMEOUT"
? "Timeout getting server stats"
@@ -1397,13 +1379,7 @@ function createSessionOpsApi(ctx) {
};
void (async () => {
try {
const responseStartedAt = performance.now();
const { stdout } = await executeStatsCommand(statsCommand, {
...statsExecOptions,
onStream: () => {
if (sshStatsConn) responseLatencyMs = Math.max(0, Math.round(performance.now() - responseStartedAt));
},
});
const { stdout } = await executeStatsCommand(statsCommand);
if (settled) return;
let combinedStdout = String(stdout || '').trim();
const primaryOutput = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?`), '');
@@ -1579,7 +1555,27 @@ function createSessionOpsApi(ctx) {
}
}
} else if (part.startsWith('GPU:')) {
({gpu,gpuName,gpuMemUsed,gpuMemTotal}=parseGpuStats(part));
// Linux: "<avgUtil> <sumMemUsed> <sumMemTotal> <first GPU name>"
const gpuStr = part.substring(4).trim();
if (gpuStr && gpuStr !== '') {
const gpuFields = gpuStr.split(/\s+/);
const util = parseInt(gpuFields[0], 10);
if (!isNaN(util)) {
gpu = Math.min(100, Math.max(0, util));
if (gpuFields.length >= 2) {
const memUsed = parseInt(gpuFields[1], 10);
if (!isNaN(memUsed)) gpuMemUsed = memUsed;
}
if (gpuFields.length >= 3) {
const memTotal = parseInt(gpuFields[2], 10);
if (!isNaN(memTotal)) gpuMemTotal = memTotal;
}
if (gpuFields.length >= 4) {
const name = gpuFields.slice(3).join(' ').trim();
if (name) gpuName = name;
}
}
}
} else if (part.startsWith('HOST:')) {
hostname = part.substring(5).trim();
} else if (part.startsWith('OS:')) {
@@ -1748,7 +1744,6 @@ function createSessionOpsApi(ctx) {
netRxSpeed, // Total network receive speed (bytes/sec)
netTxSpeed, // Total network transmit speed (bytes/sec)
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
responseLatencyMs, // Existing stats exec acknowledgement (ms), including remote channel setup
netInterfaces, // Per-interface network stats
hostname,
osName,
@@ -1,6 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { spawn, spawnSync } = require("node:child_process");
const { spawnSync } = require("node:child_process");
const { EventEmitter } = require("node:events");
const { mkdtempSync, writeFileSync, rmSync } = require("node:fs");
const { join } = require("node:path");
@@ -682,8 +682,7 @@ for (const scenario of [
{ name: "missing timeout tool", script: "exec sleep 20", gpu: null, noTimeout: true },
{ name: "blocked GPU query", script: "trap '' TERM; exec sleep 20", gpu: null },
]) {
for (const monitorOnly of [false, true]) {
test(`real stats shell preserves other metrics with ${scenario.name} (${monitorOnly ? "monitor-only" : "terminal"})`, async (t) => {
test(`real stats shell preserves other metrics with ${scenario.name}`, async (t) => {
if (spawnSync("timeout", ["-s", "KILL", "2", "true"]).status !== 0) {
t.skip("GNU/BusyBox-compatible timeout is unavailable on this test host");
return;
@@ -691,17 +690,11 @@ for (const scenario of [
const dir = mkdtempSync(getTempFilePath("gpu-stats-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
writeFileSync(join(dir, "nvidia-smi"), `#!/bin/sh\n${scenario.script}\n`, { mode: 0o755 });
let finishGpu;
const gpuDone = new Promise(resolve => {finishGpu = resolve;});
let gpuCalls = 0;
const sessions = new Map([["sid", {
type: "ssh",
_monitorStats: monitorOnly,
conn: {
exec(command, cb) {
const gpuOnly = !command.includes("NC_LATENCY_MARK") && command.includes("nvidia-smi");
if(gpuOnly) gpuCalls++;
const child = spawn("sh", ["-c", [
const execution = spawnSync("sh", ["-c", [
"uname() { printf '%s\\n' Linux; }",
"nproc() { printf '%s\\n' 4; }",
"top() { return 1; }",
@@ -710,39 +703,22 @@ for (const scenario of [
? unwrapExecShC(command).replace("timeout -s KILL 2 nvidia-smi", "nc_missing_timeout -s KILL 2 nvidia-smi")
: unwrapExecShC(command),
].join("\n")], {
encoding: "utf8",
env: { ...process.env, PATH: `${dir}:${process.env.PATH}` },
timeout: 7000,
});
let stdout="", stderr="";
child.stdout.on("data", data => {stdout+=data;});
child.stderr.on("data", data => {stderr+=data;});
child.on("error", cb);
child.on("close", code => {
assert.equal(code,0,stderr);
cb(null,fakeStream(stdout));
if(gpuOnly)finishGpu();
});
assert.equal(execution.status, 0, execution.stderr);
cb(null, fakeStream(execution.stdout));
},
},
}]]);
const api=makeSessionOps(sessions);
const sampleStarted=Date.now();
let result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
if(monitorOnly){
if(scenario.name === "blocked GPU query")assert.ok(Date.now()-sampleStarted<1500,"a hung GPU must not add its two-second timeout to CPU samples");
// Main readings arrive independently, before the optional GPU query ends.
assert.equal(result.stats.gpu,null);
await gpuDone;
await new Promise(resolve=>setImmediate(()=>setImmediate(resolve)));
result=await api.getServerStats({sender:{}},{sessionId:"sid"});
assert.equal(gpuCalls,1,"cached GPU fields must not open another probe");
}
const result = await makeSessionOps(sessions).getServerStats({ sender: {} }, { sessionId: "sid" });
assert.equal(result.success, true);
assert.equal(result.stats.cpuCores, 4);
assert.equal(result.stats.gpu, scenario.gpu);
assert.equal(result.stats.gpuMemUsed, scenario.used ?? null);
assert.equal(result.stats.gpuMemTotal, scenario.total ?? null);
});
}
}
test("getServerStats measures latency by pinging the stats connection", async () => {
@@ -1430,7 +1406,6 @@ for (const keepaliveInterval of [0, 10000]) {
assert.equal(result.success, true);
assert.equal(pingCalls, keepaliveInterval === 0 ? 0 : 1);
assert.equal(result.stats.latencyMs, keepaliveInterval === 0 ? null : 2);
assert.equal(Number.isFinite(result.stats.responseLatencyMs), true);
});
}
@@ -1474,25 +1449,3 @@ test("real macOS stats shell parses the root disk without broken awk escaping",
assert.equal(result.stats.disks[0].capacityKey, "apfs:/dev/disk3");
assert.equal(result.stats.diskPercent, 50);
});
test("monitor CPU samples return while an optional GPU driver is still blocked", async () => {
let finishGpu, gpuCalls = 0, result;
const c = {exec(command, callback) {
if (command.includes('nvidia-smi')) {
gpuCalls++;
finishGpu = () => callback(null, fakeStream('GPU:50 1024 8192 NVIDIA test'));
} else callback(null, fakeStream(selectServerStatsFixtureOutput(command, LINUX_STATS)));
}};
const api = makeSessionOps(new Map());
const pending=api.getStatsForContext({conn:c,_monitorStats:true,_monitorIntervalMs:1}).then(value=>{result=value;});
try {
for(let i=0;i<10;i++)await new Promise(resolve=>setImmediate(resolve));
assert.ok(result?.success, 'CPU and memory must not wait for the GPU probe');
assert.equal(result.stats.memTotal,8000);
assert.equal(gpuCalls,1);
} finally {
finishGpu?.();
require("./sharedStats.cjs").cancelSharedStats(c);
await pending;
}
});
+2 -4
View File
@@ -1,6 +1,5 @@
"use strict";
// One in-memory sampler per physical authenticated transport. No persistence/logging.
const { withCachedMonitorGpu, cancelMonitorGpuProbe, monitorGpuUpdatedAt } = require("./monitorGpuStats.cjs");
const { buildEndpointKey } = require("../sshConnectionPool.cjs");
let states = new WeakMap();
const identities = new Map();
@@ -101,7 +100,7 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
appendPoint(state, result, state.at, session._monitorFreshAfter);
return result;
}
// A terminal needs process and GPU fields omitted from a monitor-only query.
// A terminal needs process/GPU fields omitted from a monitor-only query.
// Finish the shared lightweight query, then upgrade once for all full readers.
await state.pending;
return sampleSharedStats(session, collect);
@@ -157,10 +156,9 @@ function readSharedStats(conn) {
const state = stateFor(conn);
if (state.points[0] && Date.now() - state.points[0].at > 30 * 60000)
state.points = state.points.filter((p) => Date.now() - p.at <= 30 * 60000);
return { result: state.latestFull ? state.latest : withCachedMonitorGpu(conn, state.latest), at: state.at, statsAt: Math.max(state.at, monitorGpuUpdatedAt(conn)), points: state.points };
return { result: state.latest, at: state.at, points: state.points };
}
function cancelSharedStats(conn) {
cancelMonitorGpuProbe(conn);
const state = states.get(conn);
if (state && !state.terminalJoined) state.controller?.abort();
}
@@ -86,7 +86,6 @@ for (const keepaliveInterval of [10000, 0]) {
assert.equal(forwardPort, 43210);
if (keepaliveInterval > 0) assert.equal(Number.isFinite(stats.stats.latencyMs), true);
else assert.equal(stats.stats.latencyMs, null);
assert.equal(Number.isFinite(stats.stats.responseLatencyMs), true);
await new Promise((resolve, reject) => conn.unforwardIn("127.0.0.1", forwardPort,
(err) => err ? reject(err) : resolve()));
}
-33
View File
@@ -1,33 +0,0 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
// Reserve the name synchronously so simultaneous downloads cannot overwrite
// each other or a file already present in the user's Downloads directory.
function reserveDownloadFile(directory, suggestedName) {
fs.mkdirSync(directory, { recursive: true });
const basename = path.basename(String(suggestedName || "download").replace(/\\/g, "/"));
const filename = !basename || basename === "." || basename === ".." ? "download" : basename;
const extension = path.extname(filename);
const stem = filename.slice(0, filename.length - extension.length);
for (let suffix = 0; ; suffix++) {
const savePath = path.join(directory, suffix ? `${stem} (${suffix})${extension}` : filename);
let descriptor;
try { descriptor = fs.openSync(savePath, "wx", 0o600); }
catch (error) { if (error.code === "EEXIST") continue; throw error; }
const reserved = fs.fstatSync(descriptor);
fs.closeSync(descriptor);
return {
savePath,
cleanupEmpty() {
try {
const current = fs.lstatSync(savePath);
if (current.isFile() && current.size === 0 && current.ino === reserved.ino && current.dev === reserved.dev) fs.unlinkSync(savePath);
} catch { /* Chromium may already have removed a cancelled download. */ }
},
};
}
}
module.exports = { reserveDownloadFile };
+2 -32
View File
@@ -2,14 +2,13 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { test } = require("node:test");
const { createDownloadStore } = require("./downloadStore.cjs");
const { reserveDownloadFile } = require("./downloadFile.cjs");
const { getTempFilePath } = require("../bridges/tempDirBridge.cjs");
test("download history stays local to its host and survives restart", () => {
const directory = fs.mkdtempSync(getTempFilePath("host-browser-downloads-"));
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "host-browser-downloads-"));
try {
const app = { getPath: () => directory };
const file = path.join(directory, "download.bin");
@@ -29,32 +28,3 @@ test("download history stays local to its host and survives restart", () => {
assert.equal(restored.list("host-b").length, 1);
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
});
test("automatic downloads reserve separate names without replacing existing files", (t) => {
const directory = fs.mkdtempSync(getTempFilePath("host-browser-download-names-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
fs.writeFileSync(path.join(directory, "report.txt"), "existing file");
fs.mkdirSync(path.join(directory, "report (1).txt"));
const first = reserveDownloadFile(directory, "report.txt");
const second = reserveDownloadFile(directory, "report.txt");
assert.equal(path.basename(first.savePath), "report (2).txt");
assert.equal(path.basename(second.savePath), "report (3).txt");
assert.equal(fs.readFileSync(path.join(directory, "report.txt"), "utf8"), "existing file");
first.cleanupEmpty();
assert.equal(fs.existsSync(first.savePath), false);
fs.writeFileSync(second.savePath, "downloaded content");
second.cleanupEmpty();
assert.equal(fs.readFileSync(second.savePath, "utf8"), "downloaded content");
});
test("cleanup removes only its own empty reservation", (t) => {
const directory = fs.mkdtempSync(getTempFilePath("host-browser-download-cleanup-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const reserved = reserveDownloadFile(directory, "../report.txt");
assert.equal(reserved.savePath, path.join(directory, "report.txt"));
const otherFile = path.join(directory, "replacement");
fs.writeFileSync(otherFile, "");
fs.renameSync(otherFile, reserved.savePath);
reserved.cleanupEmpty();
assert.equal(fs.existsSync(reserved.savePath), true, "a replacement made by someone else is left alone");
});
+1 -2
View File
@@ -39,7 +39,7 @@ function size(bytes) {
function statusText(item) {
if (item.missing) return "File moved or deleted";
if (item.status === "waiting") return "Preparing download\u2026";
if (item.status === "waiting") return "Choose where to save\u2026";
if (item.status === "progressing") return item.total
? `${size(item.received)} of ${size(item.total)} \u00b7 ${Math.min(100, Math.round(item.received * 100 / item.total))}%`
: `${size(item.received)} \u00b7 Downloading\u2026`;
@@ -112,7 +112,6 @@ function render(downloads) {
actionMenu.append(choice);
};
if (item.status === "completed" && !item.missing) addMenuAction("Open file", "open");
if (item.status === "completed" && !item.missing) addMenuAction("Save as\u2026", "save-as");
if (!["waiting", "progressing"].includes(item.status)) addMenuAction("Remove from list", "remove");
if (actionMenu.childElementCount) actions.append(more);
row.append(file, detail, actions, actionMenu);
@@ -1,2 +0,0 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>Browser renderer acceptance</title></head><body><div id="fixture"></div><script type="module" src="./renderer.tsx"></script></body></html>
@@ -1,35 +0,0 @@
// Real renderer controls with isolated desktop services, used by the Electron acceptance test.
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { HostBrowserSidePanel } from '../../../components/HostBrowserSidePanel';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '../../../components/ui/popover';
import type { TerminalSettings } from '../../../domain/models';
import '../../../index.css';
for (const [name, value] of Object.entries({ background: '210 25% 18%', foreground: '210 20% 90%', secondary: '210 20% 24%', muted: '210 20% 22%', 'muted-foreground': '210 15% 65%', border: '210 20% 30%', primary: '170 70% 42%', 'primary-foreground': '210 20% 12%', popover: '210 25% 20%' })) document.documentElement.style.setProperty(`--${name}`, value);
document.documentElement.classList.add('dark');
document.documentElement.style.setProperty('--radius', '8px');
document.documentElement.style.setProperty('--ring', '170 70% 42%');
document.documentElement.style.setProperty('--input', '210 20% 30%');
document.documentElement.style.setProperty('--primary-foreground', '210 20% 12%');
document.documentElement.style.setProperty('--destructive', '0 70% 60%');
function Fixture() {
const [visible, setVisible] = useState(true);
const [locale, setLocale] = useState('zh-CN');
return <I18nProvider locale={locale}><TooltipProvider delayDuration={100}>
<div className="flex h-screen flex-col bg-background p-3 text-foreground">
<div className="flex h-10 shrink-0 items-center gap-3">
{['上下分屏', '左右分屏'].map((label, index) => <Popover key={label}><Tooltip><TooltipTrigger asChild><PopoverTrigger asChild><button id={`split-${index}`} className="rounded px-2 py-1 text-xs hover:bg-secondary">{label}</button></PopoverTrigger></TooltipTrigger><TooltipContent side="bottom">{label}</TooltipContent></Tooltip><PopoverContent align="start" className="w-48 p-1">{Array.from({ length: 8 }, (_, item) => <button key={item} className="block w-full rounded px-2 py-2 text-left text-xs hover:bg-secondary" id={`choice-${index}-${item}`} onClick={(event) => { event.currentTarget.dataset.chosen = 'true'; }}>Panel {item + 1}</button>)}</PopoverContent></Popover>)}
<button id="visibility" className="text-xs" onClick={() => setVisible((value) => !value)}>Toggle panel</button>
<select aria-label="Locale" value={locale} onChange={(event) => setLocale(event.target.value)}>{['zh-CN', 'en', 'zh-TW', 'es', 'ru'].map((value) => <option key={value}>{value}</option>)}</select>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border/50">
<HostBrowserSidePanel browserId="local:renderer-acceptance" host={{ id: 'local', protocol: 'local', label: 'Local browser', hostname: '', username: '', tags: [], os: 'macos' }} hosts={[]} keys={[]} identities={[]} knownHosts={[]} terminalSettings={{} as TerminalSettings} isVisible={visible} />
</div>
</div>
</TooltipProvider></I18nProvider>;
}
createRoot(document.getElementById('fixture')!).render(<React.StrictMode><Fixture /></React.StrictMode>);
@@ -62,7 +62,7 @@ async function main() {
const globals = { ...React, Map, Promise, Object, crypto: require("node:crypto").webcrypto,
setTimeout, clearTimeout, queueMicrotask,
requestAnimationFrame: () => 1, cancelAnimationFrame: () => {},
document: { querySelector: () => null, querySelectorAll: () => [], documentElement: { classList: { contains: () => false } } },
document: { querySelector: () => null, documentElement: { classList: { contains: () => false } } },
getComputedStyle: () => style, netcattyBridge: { get: () => bridge }, isValidHslToken: () => false,
};
const { mountHostBrowser, currentBrowserThemeTokens } = loadModule("application/hostBrowser.ts", globals);
@@ -83,19 +83,19 @@ async function main() {
renderer = create(React.createElement(React.StrictMode, null, React.createElement(Panel)), { createNodeMock: () => element });
});
await Promise.all(opens);
const snapshot = manager.getPanelState(event, browserId);
assert.equal(snapshot.tabs[0].url, url, "the first click must open its page without a second terminal click");
const root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
&& child.webContents.getURL().endsWith("browser.html")));
assert.ok(root, "the first click must open the browser pane");
const chrome = root.children[0].webContents;
const snapshot = await chrome.executeJavaScript("window.hostBrowser.snapshot()");
assert.equal(snapshot.tabs[0].url, url, "the first click must also open its page without a second terminal click");
let loadedTitle = "";
for (let attempt = 0; attempt < 60; attempt += 1) {
loadedTitle = manager.getPanelState(event, browserId).tabs[0].title;
loadedTitle = (await chrome.executeJavaScript("window.hostBrowser.snapshot()")).tabs[0].title;
if (loadedTitle === "First click opened") break;
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(loadedTitle, "First click opened", "the first-click page must actually finish loading");
const root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
&& child.webContents.getURL() === url));
assert.ok(root, "the first click must open the browser pane");
assert.equal(root.children.length, 1, "only the website has a native view; controls belong to the app renderer");
const page = root.children.find((view) => view instanceof WebContentsView && view.webContents.getURL() === url).webContents;
assert.equal(page.getWebRTCIPHandlingPolicy(), 'disable_non_proxied_udp');
page.setWebRTCIPHandlingPolicy('default');
+11 -12
View File
@@ -3,7 +3,7 @@
// Run with: electron electron/hostBrowser/hostBrowser.live.test.cjs
const assert = require("node:assert/strict");
const net = require("node:net");
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { app, BrowserWindow, WebContentsView, session, ipcMain } = require("electron");
@@ -77,10 +77,7 @@ function makeSocksServer() {
}
async function main() {
const profile = fs.mkdtempSync(tempDirBridge.getTempFilePath("browser-standalone-"));
app.setPath("userData", profile);
fs.mkdirSync(path.join(profile, "downloads"));
app.setPath("downloads", path.join(profile, "downloads"));
app.setPath("userData", path.join(os.tmpdir(), `netcatty-host-browser-live-${process.pid}`));
const socks = makeSocksServer();
await new Promise((resolve) => socks.listen(0, "127.0.0.1", resolve));
const port = socks.address().port;
@@ -140,7 +137,7 @@ async function main() {
await page.executeJavaScript("document.querySelector('[data-icon-mode=text]').click();document.querySelector('[data-scope=all-hosts]').click()");
assert.equal(await page.executeJavaScript("document.getElementById('bookmark-scope').value"), "all-hosts");
await page.executeJavaScript("document.querySelector('[data-scope=host]').click()");
const formScreenshot = path.join(profile, "netcatty-host-browser-app-form.png");
const formScreenshot = path.join(os.tmpdir(), "netcatty-host-browser-app-form.png");
fs.writeFileSync(formScreenshot, (await page.capturePage()).toPNG());
await page.executeJavaScript("document.getElementById('bookmark-back').click()");
await page.executeJavaScript("document.querySelector('.app-tile').click()");
@@ -158,9 +155,11 @@ async function main() {
const downloadsView = browser.contentView.children[1];
assert.equal(await downloadsView.webContents.executeJavaScript("document.getElementById('empty').hidden"), false);
assert.ok(downloadsView.getBounds().height < 300);
const downloadsScreenshot = await captureView(downloadsView, path.join(profile, "netcatty-host-browser-downloads.png"));
const downloadPath = path.join(app.getPath("downloads"), "host-browser-test.bin");
const downloadsScreenshot = await captureView(downloadsView, path.join(os.tmpdir(), "netcatty-host-browser-downloads.png"));
const downloadPath = path.join(app.getPath("userData"), "host-browser-test.bin");
const slowPath = path.join(app.getPath("userData"), "slow.bin");
const websiteView = browser.contentView.children[0];
websiteView.webContents.session.on("will-download", (_event, item) => item.setSavePath(item.getFilename() === "slow.bin" ? slowPath : downloadPath));
browser.contentView.children[0].webContents.downloadURL("http://example.invalid:3000/download.bin");
for (let i = 0; i < 50 && !(await page.executeJavaScript("window.hostBrowser.listDownloads()")).some((item) => item.status === "completed"); i += 1) await wait(100);
const downloaded = (await page.executeJavaScript("window.hostBrowser.listDownloads()"))[0];
@@ -183,7 +182,7 @@ async function main() {
await downloadsView.webContents.executeJavaScript("document.querySelector('button[title=\"Cancel download\"]').click()");
for (let i = 0; i < 40 && !(await page.executeJavaScript("window.hostBrowser.listDownloads()")).some((item) => item.filename === "slow.bin" && item.status === "cancelled"); i += 1) await wait(100);
assert.equal((await page.executeJavaScript("window.hostBrowser.listDownloads()")).some((item) => item.filename === "slow.bin" && item.status === "cancelled"), true);
const downloadedScreenshot = await captureView(downloadsView, path.join(profile, "netcatty-host-browser-downloaded.png"));
const downloadedScreenshot = await captureView(downloadsView, path.join(os.tmpdir(), "netcatty-host-browser-downloaded.png"));
assert.equal(downloaded.missing, false);
await downloadsView.webContents.executeJavaScript("document.getElementById('clear').click()");
for (let i = 0; i < 20 && (await page.executeJavaScript("window.hostBrowser.listDownloads()")).length; i += 1) await wait(100);
@@ -223,7 +222,7 @@ async function main() {
await page.executeJavaScript("document.getElementById('bookmarks-menu').click()");
await wait(100);
assert.equal(await page.executeJavaScript("document.getElementById('add-app').getBoundingClientRect().right <= document.getElementById('bookmark-panel').getBoundingClientRect().left"), true);
const screenshot = path.join(profile, "netcatty-host-browser-live.png");
const screenshot = path.join(os.tmpdir(), "netcatty-host-browser-live.png");
require("node:fs").writeFileSync(screenshot, (await page.capturePage()).toPNG());
browser.setSize(720, 500);
await wait(100);
@@ -235,12 +234,12 @@ async function main() {
connectionOptions: { hostId: "live-host", type: "dynamic", hostname: "fixture", username: "test" },
});
assert.equal((await snapshot()).theme, "light");
const lightScreenshot = path.join(profile, "netcatty-host-browser-live-light.png");
const lightScreenshot = path.join(os.tmpdir(), "netcatty-host-browser-live-light.png");
require("node:fs").writeFileSync(lightScreenshot, (await page.capturePage()).toPNG());
await page.executeJavaScript("document.getElementById('downloads-menu').click()");
for (let i = 0; i < 20 && !(await snapshot()).downloadsPanelOpen; i += 1) await wait(100);
assert.equal(await downloadsView.webContents.executeJavaScript("document.body.classList.contains('dark')"), false);
const lightDownloadsScreenshot = await captureView(downloadsView, path.join(profile, "netcatty-host-browser-downloads-light.png"));
const lightDownloadsScreenshot = await captureView(downloadsView, path.join(os.tmpdir(), "netcatty-host-browser-downloads-light.png"));
await downloadsView.webContents.executeJavaScript("document.getElementById('close').click()");
assert.equal(browser.isMinimizable(), true);
assert.equal(browser.isMaximizable(), true);
@@ -6,8 +6,7 @@ const crypto = require("node:crypto");
const http = require("node:http");
const net = require("node:net");
const path = require("node:path");
const fs = require("node:fs");
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
const os = require("node:os");
const { Server } = require("ssh2");
const electron = require("electron");
const { app, BrowserWindow, WebContentsView } = electron;
@@ -63,118 +62,141 @@ async function fixture(label) {
}
async function main() {
const profile = fs.mkdtempSync(tempDirBridge.getTempFilePath("browser-panel-"));
app.setPath("userData", profile);
fs.mkdirSync(path.join(profile, "downloads"));
app.setPath("downloads", path.join(profile, "downloads"));
app.setPath("userData", path.join(os.tmpdir(), `netcatty-browser-panel-${process.pid}`));
const a = await fixture("Host A");
const b = await fixture("Host B");
const owner = new BrowserWindow({ width: 1100, height: 760, show: true });
await owner.loadURL("data:text/html,<h1>Terminal workspace</h1>");
const ownerEvents = ["blur", "resize", "maximize", "unmaximize", "closed"];
const beforeListeners = ownerEvents.map((name) => owner.listenerCount(name));
const handlers = new Map();
const manager = createHostBrowserManager({ electron: { ...electron, ipcMain: {
handle(name, handler) { handlers.set(name, handler); electron.ipcMain.handle(name, handler); },
} }, app, portForwardingBridge });
const manager = createHostBrowserManager({ electron, app, portForwardingBridge });
const event = { sender: owner.webContents };
const bounds = { x: 680, y: 110, width: 400, height: 590 };
const bounds = { x: 680, y: 50, width: 400, height: 650 };
const layout = (browserId, visible, nextBounds = bounds) => manager.updatePanelLayout(event, { browserId, visible, bounds: nextBounds });
const state = (id) => manager.getPanelState(event, id);
const command = (id, input) => manager.runCommand(event, input, id);
const call = (name, ...args) => handlers.get(`netcatty:host-browser:${name}`)(event, ...args);
const rootFor = (url) => owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView && child.webContents.getURL() === url));
const open = async (id, host) => {
manager.openPanelUrl(event, { browserId: id, url: host.url });
layout(id, true);
await manager.open(event, { browserId: id, embedded: true, hostId: host.options.hostId,
label: host.options.hostId, theme: "light", connectionOptions: host.options });
await waitFor(() => state(id).tabs[0].title, host.options.hostId);
return rootFor(host.url);
return owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
&& child.webContents.getURL().endsWith("browser.html")));
};
const idA = "workspace:session-a";
const rootA = await open(idA, a);
assert.equal(rootA.children.length, 1, "embedded browser chrome must be part of the main renderer");
const pageA = rootA.children[0];
const pageContentsA = pageA.webContents;
assert.equal(pageA.getBounds().y, 0, "the native view contains only the webpage viewport");
await pageA.webContents.executeJavaScript("window.overlayProbe = 'keep this page'");
const captured = await manager.updatePanelLayout(event, { browserId: idA, visible: true, occluded: true, bounds });
manager.openPanelUrl(event, { browserId: "workspace:session-a", url: a.url });
const rootA = await open("workspace:session-a", a);
const chromeA = rootA.children[0].webContents;
const snapshotA = () => chromeA.executeJavaScript("window.hostBrowser.snapshot()");
assert.equal((await snapshotA()).embedded, true);
assert.equal(await chromeA.executeJavaScript("document.querySelector('#route') === null"), true);
assert.equal(await chromeA.executeJavaScript("document.documentElement.scrollWidth <= innerWidth"), true);
await waitFor(async () => (await snapshotA()).tabs[0].title, "Host A");
const chromeBottom = await chromeA.executeJavaScript("document.querySelector('.chrome').getBoundingClientRect().bottom");
const pageA = rootA.children.find((child) => child instanceof WebContentsView && child.webContents.getURL() === a.url);
assert.equal(pageA.getBounds().y, chromeBottom, "page must meet the compact toolbar without a gap or overlap");
await chromeA.executeJavaScript("document.querySelector('.tab').dispatchEvent(new MouseEvent('contextmenu', {bubbles:true,clientX:30,clientY:20}))");
await waitFor(() => rootA.children.includes(pageA), false);
await chromeA.executeJavaScript("document.querySelector('#tab-icon-change').click()");
assert.equal(rootA.children.includes(pageA), false, "web content must not cover the icon editor");
assert.equal(await chromeA.executeJavaScript("document.querySelector('#tab-icon-editor').hidden"), false);
await chromeA.executeJavaScript("document.querySelector('#tab-icon-cancel').click()");
await waitFor(() => rootA.children.includes(pageA), true);
layout("workspace:session-a", false);
assert.equal(owner.contentView.children.includes(rootA), false);
assert.equal(captured.layers.length, 1);
assert.equal(electron.nativeImage.createFromDataURL(captured.layers[0].image).isEmpty(), false);
layout(idA, true);
assert.equal(await pageA.webContents.executeJavaScript("window.overlayProbe"), "keep this page");
let finishCapture;
const originalCapture = pageA.webContents.capturePage.bind(pageA.webContents);
pageA.webContents.capturePage = async () => { await new Promise((resolve) => { finishCapture = resolve; }); return originalCapture(); };
const late = manager.updatePanelLayout(event, { browserId: idA, visible: true, occluded: true, bounds });
assert.ok(finishCapture);
layout(idA, true); finishCapture();
assert.equal(await late, false);
assert.equal(owner.contentView.children.includes(rootA), true);
pageA.webContents.capturePage = originalCapture;
layout(idA, false);
// A locked desktop cannot grant focus; keep the focus check enabled by default.
if (!process.env.NETCATTY_BROWSER_SMOKE_SKIP_FOCUS) await waitFor(() => owner.webContents.isFocused(), true);
const rootB = await open("workspace:session-b", b);
assert.equal(BrowserWindow.getAllWindows().length, 1);
const chromeB = rootB.children[0].webContents;
await chromeB.executeJavaScript(`window.hostBrowser.command({type:'navigate',url:${JSON.stringify(b.url)}})`);
await waitFor(async () => (await chromeB.executeJavaScript("window.hostBrowser.snapshot()")).tabs[0].title, "Host B");
assert.equal(BrowserWindow.getAllWindows().length, 1, "panels must not create popup windows");
layout("workspace:session-b", false);
layout(idA, true, { x: 20, y: 100, width: 350, height: 500 });
assert.equal(owner.contentView.children.includes(rootB), false);
assert.equal(state(idA).tabs[0].title, "Host A");
for (let index = 0; index < 6; index++) await command(idA, { type: "new-tab" });
assert.equal(state(idA).tabs.length, 7);
layout("workspace:session-a", true, { x: 20, y: 80, width: 350, height: 500 });
assert.deepEqual(rootA.getBounds(), { x: 20, y: 80, width: 350, height: 500 });
assert.equal((await snapshotA()).tabs[0].title, "Host A", "focus switch preserves the earlier page");
assert.equal(owner.contentView.children.includes(rootB), false, "old context must leave the screen");
for (let index = 0; index < 6; index += 1) {
await chromeA.executeJavaScript("window.hostBrowser.command({type:'new-tab'})");
}
assert.equal(await chromeA.executeJavaScript(`(() => {
const button = document.querySelector('#new-tab').getBoundingClientRect();
return document.documentElement.scrollWidth <= innerWidth && button.left >= 0 && button.right <= innerWidth;
})()`), true, "tabs must scroll within a narrow pane and leave the add button reachable");
await chromeA.executeJavaScript("document.querySelector('#add-app').click()");
assert.equal(await chromeA.executeJavaScript("document.querySelector('#bookmark-panel').getBoundingClientRect().width <= innerWidth"), true);
await chromeA.executeJavaScript("document.querySelector('#bookmark-close').click()");
await chromeA.executeJavaScript("window.hostBrowser.command({type:'set-download-panel',open:true})");
const downloads = rootA.children.find((child) => child instanceof WebContentsView && child.webContents.getURL().endsWith("downloads.html"));
assert.ok(downloads);
assert.ok(downloads.getBounds().width <= rootA.getBounds().width);
assert.equal(downloads.getBounds().y, chromeBottom, "downloads must start below the compact toolbar");
const otherOwner = new BrowserWindow({ show: false });
assert.throws(() => manager.getPanelState({ sender: otherOwner.webContents }, idA), /Untrusted/);
assert.throws(() => manager.getPanelState({ sender: pageA.webContents }, idA), /Untrusted/);
assert.throws(() => manager.updatePanelLayout({ sender: otherOwner.webContents }, { browserId: idA, visible: false }), /another terminal window/);
assert.throws(() => manager.openPanelUrl(event, { browserId: idA, url: "file:\/\/\/etc/passwd" }), /HTTP and HTTPS/);
assert.throws(() => manager.updatePanelLayout({ sender: otherOwner.webContents }, { browserId: "workspace:session-a", visible: false }), /another terminal window/);
assert.throws(() => manager.openPanelUrl({ sender: otherOwner.webContents }, { browserId: "workspace:session-a", url: a.url }), /another terminal window/);
assert.throws(() => manager.openPanelUrl(event, { browserId: "workspace:session-a", url: "file:///etc/passwd" }), /HTTP and HTTPS/);
otherOwner.destroy();
const bookmark = call("bookmarks:upsert", { title: "Remote app", url: a.url, scope: "host" }, idA);
assert.ok(call("bookmarks:list", idA).some((item) => item.id === bookmark.id));
assert.equal(call("bookmarks:list", "workspace:session-b").some((item) => item.id === bookmark.id), false);
await assert.rejects(command(idA, { type: "navigate", url: "file:///etc/passwd" }), /HTTP and HTTPS/);
manager.closePanel(event, idA);
await waitFor(() => pageContentsA.isDestroyed(), true);
assert.equal(owner.webContents.isDestroyed(), false, "closing a browser must never close its main renderer");
assert.ok(a.connections() > 0 && b.connections() > 0);
manager.closePanel(event, "workspace:session-a");
await waitFor(() => chromeA.isDestroyed(), true);
assert.equal(owner.isDestroyed(), false, "closing browser must keep the terminal window alive");
const localSite = http.createServer((request, response) => {
if (request.url === '/slow.bin') { response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': 1048576 }); response.write(Buffer.alloc(16384)); return; }
response.end('<title>Local browser</title>');
if (request.url === '/slow.bin') {
response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': 1048576 });
response.write(Buffer.alloc(16384));
return;
}
response.end('<title>Local browser</title><h1>Local browser</h1>');
});
await new Promise((resolve) => localSite.listen(0, "127.0.0.1", resolve));
const localUrl = `http://127.0.0.1:${localSite.address().port}/`;
manager.openPanelUrl(event, { browserId: 'local:session', url: localUrl });
layout('local:session', true);
await manager.open(event, { browserId: 'local:session', embedded: true, local: true, hostId: 'local', label: 'Local', theme: 'light' });
await waitFor(() => state('local:session').tabs[0].title, 'Local browser');
assert.equal(state('local:session').tabs.length, 1);
const localPage = rootFor(localUrl).children[0];
manager.openPanelUrl(event, { browserId: "local:session", url: `http://127.0.0.1:${localSite.address().port}/` });
layout("local:session", true);
await manager.open(event, { browserId: "local:session", embedded: true, local: true,
hostId: "local", label: "Local terminal", theme: "light" });
const localRoot = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
&& child.webContents.getURL().endsWith("browser.html")));
const localChrome = localRoot.children[0].webContents;
await waitFor(async () => (await localChrome.executeJavaScript("window.hostBrowser.snapshot()")).tabs[0].title, "Local browser");
assert.equal((await localChrome.executeJavaScript("window.hostBrowser.snapshot()")).tabs.length, 1, "first terminal link reuses the blank tab");
const localPage = localRoot.children.find((child) => child instanceof WebContentsView && child !== localRoot.children[0]);
layout('local:session', false);
layout('local:peer', true);
await manager.open(event, { browserId: 'local:peer', embedded: true, local: true, hostId: 'local', label: 'Peer', theme: 'light' });
let download;
await manager.open(event, { browserId: 'local:peer', embedded: true, local: true,
hostId: 'local', label: 'Same host peer', theme: 'light' });
const peerRoot = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
&& child.webContents.getURL().endsWith('browser.html')));
await peerRoot.children[0].webContents.executeJavaScript("window.hostBrowser.command({type:'set-download-panel',open:true})");
const peerDownloads = peerRoot.children.find((child) => child instanceof WebContentsView && child.webContents.getURL().endsWith('downloads.html')).webContents;
let activeDownload;
const startDownload = async () => {
download = undefined;
localPage.webContents.session.once('will-download', (_event, item) => { download = item; });
localPage.webContents.downloadURL(`${localUrl}slow.bin`);
await waitFor(() => Boolean(download), true);
activeDownload = undefined;
localPage.webContents.session.once('will-download', (_downloadEvent, item) => {
activeDownload = item;
item.setSavePath(path.join(app.getPath('userData'), 'closing-download.bin'));
});
localPage.webContents.downloadURL(`http://127.0.0.1:${localSite.address().port}/slow.bin`);
await waitFor(() => Boolean(activeDownload), true);
};
await startDownload();
const items = call('downloads:list', 'local:peer');
assert.ok(items.some((item) => item.status === 'progressing' || item.status === 'waiting'));
let cancelled = false;
download.once('done', (_event, status) => { cancelled = status === 'cancelled'; });
await call('downloads:command', { type: 'cancel', id: items[0].id }, 'local:peer');
activeDownload.once('done', (_downloadEvent, status) => { cancelled = status === 'cancelled'; });
await waitFor(() => peerDownloads.executeJavaScript("Boolean(document.querySelector('button[title=\"Cancel download\"]'))"), true);
await peerDownloads.executeJavaScript("window.hostBrowserDownloads.list().then(items => window.hostBrowserDownloads.command('cancel', items[0].id))");
await waitFor(() => cancelled, true);
await waitFor(() => peerDownloads.executeJavaScript("Boolean(document.querySelector('button[title=\"Cancel download\"]'))"), false);
manager.closePanel(event, 'local:peer');
await startDownload(); cancelled = false;
download.once('done', (_event, status) => { cancelled = status === 'cancelled'; });
await startDownload();
cancelled = false;
activeDownload.once('done', (_downloadEvent, status) => { cancelled = status === 'cancelled'; });
manager.closePanel(event, 'local:session');
await waitFor(() => cancelled, true);
assert.equal(owner.webContents.isDestroyed(), false);
manager.closeAll(); localSite.closeAllConnections(); localSite.close();
await waitFor(() => localChrome.isDestroyed(), true);
assert.equal(owner.isDestroyed(), false, 'closing during a download must keep the terminal alive');
manager.closeAll();
localSite.closeAllConnections();
localSite.close();
assert.equal(owner.isDestroyed(), false);
assert.deepEqual(ownerEvents.map((name) => owner.listenerCount(name)), beforeListeners);
owner.destroy(); a.close(); b.close();
console.log(JSON.stringify({ ok: true, rendererChrome: true, hostSwitching: true, pageRetention: true, scopedControls: true, downloads: true }));
owner.destroy();
a.close(); b.close();
console.log(JSON.stringify({ ok: true, embedded: true, hostSwitching: true, pageRetention: true, popups: 0 }));
app.quit();
}
app.whenReady().then(main).catch((error) => { console.error(error); app.exit(1); });
@@ -1,101 +0,0 @@
"use strict";
// Run: electron electron/hostBrowser/hostBrowser.pdf.live.test.cjs
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const path = require("node:path");
const electron = require("electron");
const { app, BrowserWindow, WebContentsView } = electron;
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
const { createHostBrowserManager } = require("./hostBrowserManager.cjs");
const portForwardingBridge = require("../bridges/portForwardingBridge.cjs");
// A real, valid PDF whose body can be held open independently of its headers.
function pdf() {
let source = "%PDF-1.4\n";
const offsets = [0];
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 400] >>",
];
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(source));
source += `${index + 1} 0 obj\n${object}\nendobj\n`;
}
source += `%${"padding ".repeat(1024 * 128)}\n`;
const start = Buffer.byteLength(source);
source += `xref\n0 4\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n${start}\n%%EOF\n`;
return Buffer.from(source);
}
const profile = fs.mkdtempSync(tempDirBridge.getTempFilePath("browser-pdf-"));
app.setPath("userData", profile);
fs.mkdirSync(path.join(profile, "downloads"));
app.setPath("downloads", path.join(profile, "downloads"));
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function until(read, label) {
for (let index = 0; index < 120; index++) { if (await read()) return; await wait(25); }
assert.ok(await read(), label);
}
let owner, manager, site;
const watchdog = setTimeout(() => app.exit(1), 20000);
async function main() {
const body = process.env.NETCATTY_PDF_FIXTURE ? fs.readFileSync(process.env.NETCATTY_PDF_FIXTURE) : pdf();
let pending, sent = 0;
site = http.createServer((request, response) => {
if (request.url === "/failed") { request.socket.destroy(); return; }
if (request.url === "/redirect") { response.writeHead(302, { location: "/slow.pdf" }); response.end(); return; }
if (request.url === "/slow.pdf") {
response.writeHead(200, { "content-type": "application/pdf;charset=UTF-8", "content-length": body.length });
response.write(body.subarray(0, 65536)); sent = 65536; pending = response;
return;
}
response.end('<title>PDF download fixture</title><a href="/slow.pdf">PDF</a>');
});
await new Promise((resolve) => site.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${site.address().port}/`;
owner = new BrowserWindow({ width: 900, height: 700, show: true });
await owner.loadURL("data:text/html,<h1>PDF download acceptance</h1>");
const handlers = new Map();
manager = createHostBrowserManager({ electron: { ...electron, ipcMain: { handle(name, handler) { handlers.set(name, handler); } } }, app, portForwardingBridge });
const event = { sender: owner.webContents }, browserId = "local:pdf";
manager.openPanelUrl(event, { browserId, url });
manager.updatePanelLayout(event, { browserId, visible: true, bounds: { x: 0, y: 80, width: 900, height: 600 } });
await manager.open(event, { browserId, embedded: true, local: true, hostId: "local", label: "Local", theme: "light" });
await until(() => manager.getPanelState(event, browserId).tabs[0].title === "PDF download fixture", "fixture loaded");
const root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView));
const page = root.children[0].webContents;
const downloads = () => handlers.get("netcatty:host-browser:downloads:list")(event, browserId);
for (const scenario of ["link", "redirect", "iframe", "address"]) {
pending = undefined;
const previous = new Set(downloads().map((item) => item.id));
const current = () => downloads().find((item) => !previous.has(item.id));
let navigation;
if (scenario === "address") {
navigation = manager.runCommand(event, { type: "navigate", url: `${url}slow.pdf` }, browserId)
.then(() => null, (error) => error);
} else await page.executeJavaScript(`(() => {
const element=document.createElement(${JSON.stringify(scenario === "iframe" ? "iframe" : "a")});
const url=${JSON.stringify(scenario === "redirect" ? "/redirect" : "/slow.pdf")};
if(element.tagName==='IFRAME') element.src=url; else element.href=url;
document.body.append(element); if(element.tagName==='A') element.click();
})()`);
await until(() => pending, "PDF headers and first bytes were sent");
await wait(1000);
console.log(JSON.stringify({ scenario, beforeBodyFinished: true, sent, total: body.length, download: current() }));
assert.ok(current()?.status === "progressing" && current().received < body.length, "inline PDF must appear in downloads before its response finishes");
assert.ok(current().received > 0, "PDF bytes arrive before the response finishes");
if (navigation) assert.equal(await navigation, null, "a PDF download is a successful address action, not a page error");
pending.end(body.subarray(sent));
await until(() => current()?.status === "completed", "PDF completes");
assert.deepEqual(fs.readFileSync(current().savePath), body, "saved PDF matches original bytes");
}
await assert.rejects(manager.runCommand(event, { type: "navigate", url: `${url}failed` }, browserId), /ERR_/, "real navigation failures still surface");
assert.equal(page.session.listenerCount("will-download"), 1, "download handling keeps a single session listener");
console.log(JSON.stringify({ ok: true, earlyPdfProgress: true, redirect: true, iframe: true, addressDownload: true, realErrorsPreserved: true, profile }));
}
app.whenReady().then(main).catch((error) => { console.error(error); process.exitCode = 1; }).finally(() => {
clearTimeout(watchdog); manager?.closeAll(); owner?.destroy(); site?.closeAllConnections(); site?.close(); app.exit(process.exitCode || 0);
});
@@ -1,301 +0,0 @@
"use strict";
// Real React chrome, production preload and native page manager in an isolated profile.
// Run: electron electron/hostBrowser/hostBrowser.renderer.live.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const { app, BrowserWindow, WebContentsView, ipcMain } = require('electron');
const electron = require('electron');
const { createHostBrowserManager } = require('./hostBrowserManager.cjs');
const portForwardingBridge = require('../bridges/portForwardingBridge.cjs');
const tempDirBridge = require('../bridges/tempDirBridge.cjs');
const repo = path.join(__dirname, '../..');
const userData = fs.mkdtempSync(tempDirBridge.getTempFilePath('browser-renderer-'));
app.setPath('userData', userData);
const downloadsPath = path.join(userData, 'downloads');
fs.mkdirSync(downloadsPath);
app.setPath('downloads', downloadsPath);
let owner, manager, vite, site;
let pendingPdf;
let pendingNavigation;
const pdfBody = Buffer.concat([Buffer.from('%PDF-1.4\n% streamed inline PDF\n'), Buffer.alloc(1048576, 32)]);
const savedCopy = path.join(userData, 'saved-copy.txt');
const shellActions = [];
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function until(read, expected, label) {
for (let index = 0; index < 150; index++) { if (await read() === expected) return; await wait(30); }
assert.equal(await read(), expected, label);
}
const run = (code) => owner.webContents.executeJavaScript(code).catch((error) => { throw new Error(`${error.message}\nWhile executing: ${code}`); });
async function capture(file, selector) {
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
const bounds = selector ? await run(`(() => { const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)};})()`) : undefined;
fs.writeFileSync(file, (await owner.webContents.capturePage(bounds)).toPNG());
}
async function click(selector) {
const rect = await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); element?.scrollIntoView({block:'nearest'}); const r = element?.getBoundingClientRect(); return r && {x:r.x+r.width/2,y:r.y+r.height/2}; })()`);
assert.ok(rect, `missing ${selector}`);
for (const type of ['mouseDown', 'mouseUp']) owner.webContents.sendInputEvent({ type, button: 'left', clickCount: 1, x: Math.round(rect.x), y: Math.round(rect.y) });
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
}
async function input(selector, value) {
await until(() => run(`!!document.querySelector(${JSON.stringify(selector)})`), true, `input exists: ${selector}`);
await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(element,${JSON.stringify(value)}); element.dispatchEvent(new Event('input',{bubbles:true})); })()`);
}
const watchdog = setTimeout(() => { console.error('Browser renderer acceptance timed out'); app.exit(1); }, 90000);
async function main() {
const { createServer } = await import('vite');
vite = await createServer({ root: repo, server: { host: '127.0.0.1', port: 0, strictPort: false }, clearScreen: false, logLevel: 'error' });
await vite.listen();
const base = `http://127.0.0.1:${vite.httpServer.address().port}`;
site = http.createServer((request, response) => {
if (request.url === '/slow-page') {
response.writeHead(200, { 'content-type': 'text/html' });
response.write('<title>Slow navigation</title><input autofocus>'); pendingNavigation = response; return;
}
if (request.url === '/inline.pdf') {
response.writeHead(200, { 'content-type': 'application/pdf;charset=UTF-8', 'content-length': pdfBody.length,
'content-disposition': "inline; filename*=UTF-8''%E6%85%A2%E9%80%9F%E6%8A%A5%E5%91%8A.pdf" });
response.write(pdfBody.subarray(0, 65536)); pendingPdf = response; return;
}
if (request.url === '/report.txt') {
response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-disposition': 'attachment; filename="report.txt"' });
response.end('Netcatty automatic download\n'); return;
}
if (request.url === '/slow.bin') {
response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': 1048576 });
response.write(Buffer.alloc(16384)); return;
}
response.end(`<html><title>Browser acceptance page</title><body style="background:#c8f1df;color:#142a25;font:22px sans-serif;padding:25px"><h1>This page stays visible</h1><input id="draft" value="Keep my input"><button id="count" onclick="window.count=(window.count||0)+1">Count</button><a id="next" href="/next">Next</a><a id="download" href="/report.txt">Download report</a><p>${request.url}</p></body></html>`);
});
await new Promise((resolve) => site.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${site.address().port}/`;
const preload = path.join(userData, 'preload.cjs');
fs.writeFileSync(preload, `const {contextBridge,ipcRenderer,webUtils}=require('electron'); const {createPreloadApi}=require(${JSON.stringify(path.join(repo,'electron/preload/api.cjs'))}); contextBridge.exposeInMainWorld('netcatty',createPreloadApi({ipcRenderer,webUtils}));`);
owner = new BrowserWindow({ width: 1000, height: 800, show: true, webPreferences: { preload, contextIsolation: true, sandbox: false } });
const errors = [];
owner.webContents.on('console-message', (event) => { if (event.level === 'error' || event.level >= 3) errors.push(event.message); });
owner.webContents.on('render-process-gone', (_event, details) => errors.push(JSON.stringify(details)));
manager = createHostBrowserManager({ electron: { ...electron,
dialog: { showSaveDialog: async () => ({ canceled: false, filePath: savedCopy }) },
shell: { openPath: async (file) => { shellActions.push(['open', file]); return ''; }, showItemInFolder: (file) => shellActions.push(['reveal', file]) },
}, app, portForwardingBridge });
const event = { sender: owner.webContents };
const browserId = 'local:renderer-acceptance';
manager.openPanelUrl(event, { browserId, url });
await owner.loadURL(`${base}/electron/hostBrowser/fixtures/renderer.html`);
await until(() => run('document.querySelectorAll("[role=tab]").length'), 1, 'unified tab mounts');
await until(() => manager.getPanelState(event, browserId).tabs[0].title, 'Browser acceptance page', 'page loads');
let root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView && child.webContents.getURL() === url));
assert.ok(root); assert.equal(root.children.length, 1, 'only the website is a separate native view');
const page = root.children[0].webContents;
await page.executeJavaScript("document.getElementById('draft').value='Unsaved browser input'");
for (let index = 0; index < 2; index++) {
app.focus({ steal: true });
owner.focus(); owner.webContents.focus();
await until(() => owner.isFocused(), true, 'acceptance window has native focus');
owner.webContents.sendInputEvent({ type: 'mouseMove', x: 980, y: 740 });
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
const rect = await run(`(() => { const r = document.getElementById('split-${index}').getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2}; })()`);
owner.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(rect.x), y: Math.round(rect.y) });
await until(() => run('!!document.querySelector("[role=tooltip]")'), true, 'split hover tooltip opens');
await wait(100);
assert.equal(owner.contentView.children.includes(root), true, 'hover leaves the website live');
assert.equal(await run('document.querySelectorAll("[data-section=host-browser-panel] img").length'), 0, 'hover does not replace the page with a frozen picture');
await click(`#split-${index}`);
await until(() => run('document.querySelectorAll("[data-section=host-browser-panel] img").length'), 1, 'overlapping split menu preserves a picture of the webpage');
assert.equal(owner.contentView.children.includes(root), false, 'the split menu is above the native content');
await click(`#choice-${index}-0`);
await until(() => run(`document.getElementById('choice-${index}-0').dataset.chosen`), 'true', 'split menu choices are clickable');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
owner.webContents.sendInputEvent({ type: 'mouseMove', x: 980, y: 740 });
await until(() => owner.contentView.children.includes(root), true, 'closing menu restores live content');
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"), 'Unsaved browser input');
}
await click('[aria-label="新标签页"]');
await until(() => run('document.querySelectorAll("[role=tab]").length'), 2, 'new tab');
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'new tab focuses the app address input');
assert.equal(owner.contentView.children.includes(root), false, 'the default tab must not be covered by an empty native container');
await click('[role=tab]');
await until(() => owner.contentView.children.includes(root), true, 'return to original tab');
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"), 'Unsaved browser input');
page.sendInputEvent({ type: 'keyDown', keyCode: 'L', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'page shortcut focuses unified address input');
await input('[aria-label="输入网址"]', `${url}next`);
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
await until(() => page.getURL(), `${url}next`, 'address navigation');
await until(() => run('document.querySelector("[aria-label=后退]").disabled'), false, 'history back is enabled');
await click('[aria-label="后退"]');
await until(() => page.getURL(), url, 'back navigation');
await click('[aria-label="应用与书签"]');
await until(() => run('!!document.querySelector("aside[aria-label=应用与书签]")'), true, 'bookmarks share the app layout');
await until(() => root.getBounds().width < 800, true, 'bookmark sidebar resizes only the webpage viewport');
await click('aside [aria-label="添加应用"]');
await until(() => run('!!document.querySelector("aside input[type=url]")'), true, 'bookmark editor');
await input('aside input[maxlength="120"]', 'Saved acceptance app');
await input('aside input[type="url"]', url);
await click('aside [data-section="browser-icon-editor"] button:nth-child(3)');
await run(`(() => {
const canvas=document.createElement('canvas'); canvas.width=640; canvas.height=320;
const context=canvas.getContext('2d'); context.fillStyle='#247969'; context.fillRect(0,0,640,320);
context.fillStyle='white'; context.font='bold 180px sans-serif'; context.fillText('N',245,225);
const source=atob(canvas.toDataURL('image/png').split(',')[1]);
const bytes=Uint8Array.from(source,c=>c.charCodeAt(0));
const files=new DataTransfer(); files.items.add(new File([bytes],'team-app.png',{type:'image/png'}));
const element=document.querySelector('aside input[type=file]'); element.files=files.files; element.dispatchEvent(new Event('change',{bubbles:true}));
})()`);
await until(() => run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'chosen image has a real preview');
assert.equal(await run('document.querySelector("aside input[type=file]").classList.contains("hidden")'), true, 'native file picker chrome is hidden');
assert.equal(await run('document.querySelector("aside button[type=submit]").disabled'), false, 'processed image can be saved');
const imageSize = await run(`new Promise(resolve=>{const image=new Image();image.onload=()=>resolve([image.width,image.height]);image.src=document.querySelector('aside img[alt=图标预览]').src;})`);
assert.deepEqual(imageSize, [96, 48], 'large images are resized proportionally');
await click('aside [aria-label="移除图片"]');
await until(() => run('document.querySelector("aside button[type=submit]").disabled'), true, 'an empty image choice cannot be saved');
await click('aside [data-section="browser-icon-editor"] button:nth-child(2)');
await input('aside [aria-label="图标文字"]', 'N');
await click('aside label:has(input[value="all-hosts"])');
const editorTextScreenshot = path.join(userData, 'bookmark-editor-text.png');
await capture(editorTextScreenshot, 'aside');
await click('aside [data-section="browser-icon-editor"] button:nth-child(3)');
await run(`(() => {
const canvas=document.createElement('canvas');canvas.width=96;canvas.height=96;
const context=canvas.getContext('2d');context.fillStyle='#247969';context.fillRect(0,0,96,96);context.fillStyle='white';context.font='bold 58px sans-serif';context.fillText('N',25,69);
const source=atob(canvas.toDataURL().split(',')[1]); const files=new DataTransfer();
files.items.add(new File([Uint8Array.from(source,c=>c.charCodeAt(0))],'new-icon.png',{type:'image/png'}));
document.querySelector('aside input[type=file]').parentElement.dispatchEvent(new DragEvent('drop',{bubbles:true,dataTransfer:files}));
})()`);
await until(() => run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'a dropped replacement image loads');
await run(`(() => {
const files=new DataTransfer();files.items.add(new File(['not an image'],'invalid.txt',{type:'text/plain'}));
const element=document.querySelector('aside input[type=file]');element.files=files.files;element.dispatchEvent(new Event('change',{bubbles:true}));
})()`);
await until(() => run('!!document.querySelector("aside [role=alert]")'), true, 'unsupported files show an inline explanation');
assert.equal(await run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'a failed replacement keeps the previous image');
for (const [source, expected] of [["new Uint8Array(6*1024*1024)", '图片太大'], ["'broken PNG'", '无法读取']]) {
await run(`(() => {const files=new DataTransfer();files.items.add(new File([${source}],'bad.png',{type:'image/png'}));const input=document.querySelector('aside input[type=file]');input.files=files.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(() => run(`document.querySelector('aside [role=alert]')?.textContent.includes(${JSON.stringify(expected)})`), true, 'invalid image has a specific inline explanation');
}
await run('document.querySelector("aside form").requestSubmit()');
await until(() => run('!!Array.from(document.querySelectorAll("aside button")).find(b=>b.textContent.includes("Saved acceptance app"))'), true, 'saved bookmark is shown');
await click('aside [aria-label="编辑"]');
await until(() => run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'the saved image is restored when editing');
assert.equal(await run('document.querySelector("aside input[value=all-hosts]").checked'), true, 'scope survives saving');
const editorScreenshot=path.join(userData,'bookmark-editor-image.png');
await capture(editorScreenshot, 'aside');
for (const width of [400, 1000]) {
owner.setSize(width, 800); await wait(150);
assert.equal(await run('document.querySelector("aside").scrollWidth <= document.querySelector("aside").clientWidth'), true, 'the editor fits a narrow panel');
await click('aside button[type=submit]');
await until(() => run('!document.querySelector("aside input[type=url]")'), true, 'footer remains reachable after resizing');
await click('aside [aria-label="编辑"]');
await until(() => run('!!document.querySelector("aside input[type=url]")'), true, 'reopen after resizing');
}
await click('aside [aria-label="关闭"]');
await click('[role=tab]:nth-child(2)');
await until(() => owner.contentView.children.includes(root), false, 'returning to the default tab removes the native input blocker');
await click('[data-section=host-browser-panel] button');
await until(() => run('!!document.querySelector("aside input[type=url]")'), true, 'default-tab Add app opens the editor');
await click('aside [aria-label="关闭"]');
await click(`[data-section=host-browser-panel] button[title="${url}"]`);
await until(() => manager.getPanelState(event, browserId).tabs.find(tab => tab.id === manager.getPanelState(event, browserId).activeTabId)?.url, url, 'default-tab bookmark opens its website');
await until(() => owner.contentView.children.includes(root), true, 'bookmark navigation attaches the website again');
await click('[aria-label="下载"]');
await until(() => run('document.body.textContent.includes("暂无下载")'), true, 'downloads use the app popover');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
fs.writeFileSync(path.join(downloadsPath, 'report.txt'), 'Keep the existing file');
const activePage = root.children[0].webContents;
await activePage.executeJavaScript("document.getElementById('download').click()");
await until(() => run('document.body.textContent.includes("report (1).txt") && document.body.textContent.includes("已完成")'), true, 'a website download saves immediately and opens its progress list');
await until(() => !owner.contentView.children.includes(root), true, 'the automatic download list is above the native webpage before interacting');
assert.equal(fs.readFileSync(path.join(downloadsPath, 'report (1).txt'), 'utf8'), 'Netcatty automatic download\n');
assert.equal(fs.readFileSync(path.join(downloadsPath, 'report.txt'), 'utf8'), 'Keep the existing file');
await click('[aria-label="更多操作"]');
await until(() => run('Array.from(document.querySelectorAll("button")).some(button => button.textContent === "另存为…")'), true, 'download actions menu opens');
await run('Array.from(document.querySelectorAll("button")).find(button => button.textContent === "另存为…").click()');
await until(() => fs.existsSync(savedCopy), true, 'Save as writes a separate copy');
assert.equal(fs.readFileSync(savedCopy, 'utf8'), 'Netcatty automatic download\n');
assert.equal(fs.existsSync(path.join(downloadsPath, 'report (1).txt')), true, 'Save as retains the original downloaded file');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await click('[title="在文件夹中显示"]');
assert.deepEqual(shellActions.at(-1), ['reveal', path.join(downloadsPath, 'report (1).txt')]);
await click('[aria-label="打开下载文件夹"]');
assert.deepEqual(shellActions.at(-1), ['open', downloadsPath]);
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await activePage.executeJavaScript("document.getElementById('download').click()");
await until(() => run('document.body.textContent.includes("report (2).txt") && document.querySelectorAll("[data-download-id]").length === 2'), true, 'a second download also gets a new name and reopens the list');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
activePage.downloadURL(`${url}slow.bin`);
await until(() => run('!!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'progress is visible while the server is still sending the file');
await until(() => !owner.contentView.children.includes(root), true, 'the progress popover has acquired the webpage surface');
await click('[aria-label="下载"]');
await until(() => run('!!document.querySelector("[aria-label=取消下载]")'), false, 'the download toggle closes its progress list');
await wait(350);
assert.equal(await run('!!document.querySelector("[aria-label=取消下载]")'), false, 'progress updates do not reopen a dismissed list');
await click('[aria-label="下载"]');
await click('[aria-label="取消下载"]');
await until(() => run('document.body.textContent.includes("已取消")'), true, 'download can be cancelled from the list');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await activePage.executeJavaScript("(() => { const a=document.createElement('a');a.href='/inline.pdf';document.body.append(a);a.click();a.remove(); })()");
await until(() => run('document.body.textContent.includes("慢速报告.pdf") && !!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'inline PDF navigation opens progress before the response finishes');
assert.equal(pendingPdf.writableEnded, false, 'the server has not sent the rest of the PDF');
await until(() => run('document.body.textContent.includes("64.0 KB")'), true, 'received PDF bytes are visible while its body is held open');
const pdfProgressScreenshot = path.join(userData, 'inline-pdf-progress.png');
await capture(pdfProgressScreenshot);
await click('[aria-label="取消下载"]');
await until(() => pendingPdf.destroyed, true, 'cancelling an inline PDF stops the network transfer');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
pendingPdf = undefined;
await input('[aria-label="输入网址"]', `${url}inline.pdf`);
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
await until(() => !!pendingPdf && !pendingPdf.destroyed, true, 'another inline PDF starts');
await until(() => run('!!document.querySelector("[aria-label=取消下载]")'), true, 'another PDF also opens progress');
pendingPdf.end(pdfBody.subarray(65536));
await until(() => run('Array.from(document.querySelectorAll("[data-download-id]")).some(row => row.textContent.includes("慢速报告.pdf") && row.textContent.includes("已完成"))'), true, 'PDF progress becomes completed after the response finishes');
assert.deepEqual(fs.readFileSync(path.join(downloadsPath, '慢速报告.pdf')), pdfBody, 'the PDF is saved without changing its contents');
assert.equal(await run('document.body.textContent.includes("ERR_FAILED")'), false, 'address-bar downloads do not show a false page error');
const downloadScreenshot=path.join(userData,'automatic-downloads.png');
await capture(downloadScreenshot);
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await input('[aria-label="输入网址"]', `${url}slow-page`);
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
await until(() => !!pendingNavigation, true, 'slow navigation is waiting for its response to finish');
await click('[aria-label="新标签页"]');
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'new tab receives input focus while the previous page is loading');
await input('[aria-label="输入网址"]', 'keep my address draft');
pendingNavigation.end();
await until(() => !activePage.isLoading(), true, 'the background page finishes loading');
await wait(100);
assert.equal(owner.webContents.isFocused(), true, 'background navigation must not steal the new tab input focus');
assert.equal(await run('document.activeElement.getAttribute("aria-label")'), '输入网址');
assert.equal(await run('document.querySelector("[aria-label=输入网址]").value'), 'keep my address draft');
await click('[role=tab]:nth-child(2)');
await until(() => owner.contentView.children.includes(root), true, 'return to the loaded page');
await click('#visibility');
await until(() => owner.contentView.children.includes(root), false, 'hidden terminal hides only its page');
await click('#visibility');
await until(() => owner.contentView.children.includes(root), true, 'returning terminal restores page');
for (const width of [400, 1000]) {
owner.setSize(width, 800); await wait(150);
assert.equal(await run('document.documentElement.scrollWidth <= innerWidth'), true, 'no document overflow at narrow and wide sizes');
}
for (const locale of ['en', 'zh-TW', 'es', 'ru', 'zh-CN']) {
await run(`(() => { const el=document.querySelector('select'); const setter=Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value').set; setter.call(el,${JSON.stringify(locale)}); el.dispatchEvent(new Event('change',{bubbles:true})); })()`);
await wait(50);
assert.equal(await run('document.body.textContent.includes("terminal.browser.")'), false, 'no untranslated browser labels');
}
const screenshot = path.join(userData, 'unified-browser.png');
fs.writeFileSync(screenshot, (await owner.webContents.capturePage()).toPNG());
assert.deepEqual(errors, [], 'no renderer errors');
console.log(JSON.stringify({ ok: true, rendererChrome: true, hoverKeepsPageLive: true, splitMenusClickable: true, tabs: true, addressAndHistory: true, shortcuts: true, bookmarks: true, iconUpload: true, imageResize: true, imageReplaceAndRemove: true, editPersistence: true, downloads: true, automaticDownloads: true, duplicateNames: true, saveAs: true, downloadProgressAndCancel: true, inlinePdfProgressAndCancel: true, hiddenRestore: true, narrowLayout: true, locales: 5, screenshot, editorScreenshot, editorTextScreenshot, downloadScreenshot, pdfProgressScreenshot }));
}
app.whenReady().then(main).catch(async (error) => { console.error(error); if (owner && !owner.isDestroyed()) { const screenshot=path.join(userData,'failure.png'); fs.writeFileSync(screenshot,(await owner.webContents.capturePage()).toPNG()); console.error(screenshot); } process.exitCode = 1; }).finally(async () => {
clearTimeout(watchdog);
manager?.closeAll(); if (owner && !owner.isDestroyed()) owner.destroy();
if (site) { site.closeAllConnections(); site.close(); }
await vite?.close();
// Keep the isolated screenshot and profile under Netcatty's managed temp directory for inspection.
app.exit(process.exitCode || 0);
});
+53 -169
View File
@@ -5,33 +5,18 @@ const fs = require("node:fs");
const { createHash, randomUUID } = require("node:crypto");
const { createBookmarkStore } = require("./bookmarkStore.cjs");
const { createDownloadStore } = require("./downloadStore.cjs");
const { reserveDownloadFile } = require("./downloadFile.cjs");
const { createSiteIconStore, iconOrigin, validImageDataUrl } = require("./siteIconStore.cjs");
const { windowsFramelessContentChromeOptions } = require("../bridges/windowManager/windowsWindowChrome.cjs");
const DEAD_PROXY = "socks5://127.0.0.1:9";
const CHROME_HEIGHT = 80;
const chromeHeight = (record) => Math.round((record.embedded ? 0 : CHROME_HEIGHT) * (record.zoom || 1));
const EMBEDDED_CHROME_HEIGHT = 64;
const chromeHeight = (record) => Math.round((record.embedded ? EMBEDDED_CHROME_HEIGHT : CHROME_HEIGHT) * (record.zoom || 1));
const MAX_TABS = 32;
const DOWNLOAD_PANEL_WIDTH = 372;
const ALLOWED_PAGE_SCHEMES = new Set(["http:", "https:", "ws:", "wss:", "blob:", "data:"]);
const THEME_TOKEN_NAMES = ["background", "foreground", "secondary", "muted", "muted-foreground", "border", "primary", "primary-foreground"];
function pdfDownloadHeaders({ resourceType, statusCode, responseHeaders }) {
// The page allowlist deliberately excludes Chromium's extension-based PDF
// viewer. Hand PDF navigations to the download manager at response headers,
// before that viewer can consume/cache the body outside download tracking.
if (!["mainFrame", "subFrame"].includes(resourceType) || statusCode < 200 || statusCode >= 300 || !responseHeaders) return;
const typeKey = Object.keys(responseHeaders).find((key) => key.toLowerCase() === "content-type");
if (responseHeaders[typeKey]?.[0]?.split(";", 1)[0].trim().toLowerCase() !== "application/pdf") return;
const dispositionKey = Object.keys(responseHeaders).find((key) => key.toLowerCase() === "content-disposition") || "Content-Disposition";
const disposition = responseHeaders[dispositionKey]?.[0] || "";
if (/^\s*attachment\s*(;|$)/i.test(disposition)) return;
// Keep the site's filename / filename* parameters, including localized names.
const parameters = disposition.indexOf(";");
return { ...responseHeaders, [dispositionKey]: [`attachment${parameters < 0 ? "" : disposition.slice(parameters)}`] };
}
function validThemeTokens(input) {
const result = {};
for (const name of THEME_TOKEN_NAMES) {
@@ -89,7 +74,7 @@ function normalizeBrowserUrl(input) {
}
function createHostBrowserManager({ electron, app, portForwardingBridge }) {
const { BrowserWindow, WebContentsView, session, ipcMain, shell, dialog } = electron;
const { BrowserWindow, WebContentsView, session, ipcMain, shell } = electron;
const windows = new Map();
const panelLayouts = new Map();
const byChromeId = new Map();
@@ -98,7 +83,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
const downloadStore = createDownloadStore(app);
const bookmarkStore = createBookmarkStore(app, () => {
for (const record of windows.values()) {
if (record.embedded && !record.chromeContents.isDestroyed()) panelEvent(record, { type: "bookmarks" });
if (record.embedded && !record.chromeContents.isDestroyed()) record.chromeContents.send("netcatty:host-browser:bookmarks-changed");
}
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
@@ -106,21 +91,16 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
}
}
});
const panelEvent = (record, event) => {
if (!record.sourceWebContents.isDestroyed()) record.sourceWebContents.send("netcatty:host-browser:panel-event", { browserId: record.id, ...event });
};
const send = (record) => {
if (record.win.isDestroyed() || record.chromeContents.isDestroyed()) return;
if (record.embedded) panelEvent(record, { type: "state", state: snapshot(record) });
else record.chromeContents.send("netcatty:host-browser:state", snapshot(record));
record.chromeContents.send("netcatty:host-browser:state", snapshot(record));
};
const notifyDownloads = (record, startedId) => {
const notifyDownloads = (record) => {
const downloads = downloadStore.list(record.hostId);
for (const target of windows.values()) {
if (target.hostId !== record.hostId || target.win.isDestroyed() || target.chromeContents.isDestroyed()) continue;
if (target.downloadsPanelOpen) positionDownloadsView(target);
if (target.embedded) panelEvent(target, { type: "downloads", downloads, startedId: target === record ? startedId : undefined });
else target.chromeContents.send("netcatty:host-browser:downloads-changed", downloads);
target.chromeContents.send("netcatty:host-browser:downloads-changed", downloads);
if (target.downloadsView && !target.downloadsView.webContents.isDestroyed()) {
target.downloadsView.webContents.send("netcatty:host-browser:downloads-changed", downloads);
}
@@ -146,10 +126,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
});
const notifySiteIcon = (record) => {
send(record);
if (!record.win.isDestroyed()) {
if (record.embedded) panelEvent(record, { type: "bookmarks" });
else record.chromeContents.send("netcatty:host-browser:site-icons-changed");
}
if (!record.win.isDestroyed()) record.chromeContents.send("netcatty:host-browser:site-icons-changed");
};
const updateFavicon = async (record, tab, urls) => {
if (!tab.url || record.route !== "connected") return;
@@ -190,7 +167,6 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
send(record);
};
const setDownloadsPanel = async (record, open) => {
if (record.embedded) return;
if (!open) { closeDownloadsPanel(record); return; }
if (!record.downloadsView) {
const view = new WebContentsView({ webPreferences: {
@@ -240,18 +216,6 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
try { record.container.removeChildView(record.downloadsView); } catch {}
record.container.addChildView(record.downloadsView);
}
if (record.embedded) {
const attached = record.win.contentView.children.includes(record.container);
// An empty native View still intercepts mouse input on macOS. The
// renderer owns the default tab and connection screens, so attach this
// container only while it contains a website.
if (record.container.children.length) {
if (!attached) record.win.contentView.addChildView(record.container);
} else if (attached) {
record.win.contentView.removeChildView(record.container);
record.sourceWebContents.focus();
}
}
send(record);
};
const refreshTab = (record, tab) => {
@@ -287,11 +251,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
const type = { t: "new-tab", w: "close-tab", r: "reload", l: "focus-address" }[key];
if (!type) return;
event.preventDefault();
if (type === "focus-address" || type === "new-tab") record.chromeContents.focus();
if (!record.win.isDestroyed()) {
if (record.embedded) panelEvent(record, { type: "shortcut", shortcut: { type, tabId: tab.id } });
else record.chromeContents.send("netcatty:host-browser:shortcut", { type, tabId: tab.id });
}
if (!record.win.isDestroyed()) record.chromeContents.send("netcatty:host-browser:shortcut", { type, tabId: tab.id });
});
contents.setWindowOpenHandler(({ url }) => {
try { addTab(record, normalizeBrowserUrl(url)); } catch {}
@@ -353,7 +313,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
record.sourceWebContents.off("destroyed", record.onOwnerClosed);
if (record.embedded) {
try { record.win.contentView.removeChildView(record.container); } catch {}
if (record.chromeView) { try { record.chromeContents.close(); } catch {} }
try { record.chromeContents.close(); } catch {}
}
byChromeId.delete(record.chromeId);
if (record.downloadsView) {
@@ -460,7 +420,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
record.themeTokens = validThemeTokens(layout.themeTokens);
record.downloadsView?.webContents.send("netcatty:host-browser:theme", { theme: record.theme, themeTokens: record.themeTokens });
}
const visible = layout?.sender === record.sourceWebContents && layout.visible === true && !layout.occluded;
const visible = layout?.sender === record.sourceWebContents && layout.visible === true;
const attached = record.win.contentView.children.includes(record.container);
if (!visible) {
if (attached) {
@@ -485,28 +445,13 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
return;
}
record.container.setBounds(record.bounds);
record.chromeView.setBounds({ x: 0, y: 0, width, height });
record.chromeContents.setZoomFactor(zoom);
for (const tab of record.tabs.values()) tab.view.webContents.setZoomFactor(zoom);
if (!attached) record.win.contentView.addChildView(record.container);
resizeView(record);
displayTab(record);
};
const capturePanelLayers = (record) => {
if (record.panelSnapshotPromise) return record.panelSnapshotPromise;
const zoom = record.zoom || 1;
const views = record.container.children.filter((view) => view instanceof WebContentsView);
const operation = Promise.all(views.map(async (view) => {
const bounds = view.getBounds();
const image = await view.webContents.capturePage();
if (image.isEmpty()) return null;
return { image: image.toDataURL(), bounds: Object.fromEntries(
Object.entries(bounds).map(([key, value]) => [key, value / zoom]),
) };
})).then((layers) => layers.filter(Boolean)).catch(() => []);
record.panelSnapshotPromise = operation;
void operation.finally(() => {
if (record.panelSnapshotPromise === operation) record.panelSnapshotPromise = null;
});
return operation;
};
const updatePanelLayout = (event, input) => {
const owner = BrowserWindow.fromWebContents(event.sender);
if (!owner || owner.webContents !== event.sender) throw new Error("Untrusted browser panel owner");
@@ -520,18 +465,8 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
const bounds = input.bounds;
if (input.visible && (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite)
|| bounds.width < 0 || bounds.height < 0)) throw new Error("Invalid browser panel bounds");
const layout = { sender: event.sender, visible: input.visible === true, occluded: input.occluded === true, bounds, pendingUrls: previous?.pendingUrls ?? [],
theme: input.theme === "light" || input.theme === "dark" ? input.theme : undefined, themeTokens: validThemeTokens(input.themeTokens) };
panelLayouts.set(id, layout);
if (record && layout.visible && layout.occluded) {
return capturePanelLayers(record).then((layers) => {
// A menu may close, the pane may move or the owner may disappear while
// capture is pending. Never hide a newer layout or return stale pixels.
if (panelLayouts.get(id) !== layout || windows.get(id) !== record || owner.isDestroyed()) return false;
applyPanelLayout(record);
return { layers };
});
}
panelLayouts.set(id, { sender: event.sender, visible: input.visible === true, bounds, pendingUrls: previous?.pendingUrls ?? [],
theme: input.theme === "light" || input.theme === "dark" ? input.theme : undefined, themeTokens: validThemeTokens(input.themeTokens) });
if (record) applyPanelLayout(record);
return true;
};
@@ -608,30 +543,18 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
themeTokens: validThemeTokens(input?.themeTokens),
pageSession, route: "connecting", error: null, panelOpen: false, overlayOpen: false,
tunnelId: null, connectionOptions: input.connectionOptions, sourceWebContents: event.sender,
routeGeneration: 0, proxyQueue: Promise.resolve(), tabs: new Map(), downloads: new Map(), navigations: new Map(),
routeGeneration: 0, proxyQueue: Promise.resolve(), tabs: new Map(), downloads: new Map(),
downloadsView: null, downloadsPanelOpen: false, activeTabId: null, win: null };
record.downloadHandler = (event, item, contents) => {
const ownedPage = [...record.tabs.values()].some((tab) => tab.view.webContents === contents);
if (record.route !== "connected" || !ownedPage) { event.preventDefault(); return; }
const id = randomUUID();
const filename = path.basename(String(item.getFilename() || "download").replace(/\\/g, "/")) || "download";
let destination;
try {
destination = reserveDownloadFile(app.getPath("downloads"), filename);
item.setSavePath(destination.savePath);
} catch {
destination?.cleanupEmpty();
event.preventDefault();
downloadStore.upsert({ id, hostId, filename, url: item.getURL(), savePath: "", status: "interrupted",
received: 0, total: item.getTotalBytes(), startedAt: Date.now(), endedAt: Date.now() });
notifyDownloads(record, id);
return;
}
item.setSaveDialogOptions({ defaultPath: path.join(app.getPath("downloads"), filename) });
record.downloads.set(id, item);
downloadStore.upsert({ id, hostId, filename: path.basename(destination.savePath), url: item.getURL(), savePath: destination.savePath,
status: "progressing", received: 0, total: item.getTotalBytes(), startedAt: Date.now() });
notifyDownloads(record, id);
if (!record.embedded) void setDownloadsPanel(record, true).catch(() => {});
downloadStore.upsert({ id, hostId, filename, url: item.getURL(), savePath: "",
status: "waiting", received: 0, total: item.getTotalBytes(), startedAt: Date.now() });
notifyDownloads(record);
let lastProgressAt = 0;
item.on("updated", (_updateEvent, status) => {
const now = Date.now();
@@ -646,7 +569,6 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
});
item.once("done", (_doneEvent, status) => {
record.downloads.delete(id);
if (status !== "completed") destination.cleanupEmpty();
const previous = downloadStore.get(hostId, id);
if (!previous) return;
const savePath = item.getSavePath() || previous.savePath;
@@ -659,20 +581,12 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
pageSession.webRequest.onBeforeRequest((details, callback) => {
let allowed = false;
try { allowed = record.route === "connected" && ALLOWED_PAGE_SCHEMES.has(new URL(details.url).protocol); } catch {}
const navigation = record.navigations.get(details.webContentsId);
if (allowed && details.resourceType === "mainFrame" && navigation?.url === details.url) navigation.requestId = details.id;
callback({ cancel: !allowed });
});
pageSession.webRequest.onHeadersReceived((details, callback) => {
const responseHeaders = pdfDownloadHeaders(details);
const navigation = record.navigations.get(details.webContentsId);
const headers = responseHeaders || details.responseHeaders || {};
if (navigation?.requestId === details.id && details.resourceType === "mainFrame" && details.statusCode >= 200 && details.statusCode < 300
&& Object.entries(headers).some(([key, values]) => key.toLowerCase() === "content-disposition" && /^\s*attachment\s*(;|$)/i.test(values[0]))) {
navigation.downloading = true;
}
callback(responseHeaders ? { responseHeaders } : {});
});
const chromeOptions = { webPreferences: {
preload: path.join(__dirname, "browserPreload.cjs"),
sandbox: true, contextIsolation: true, nodeIntegration: false,
} };
const win = embedded ? owner : new BrowserWindow({
title: "Browser",
width: 1100, height: 760, minWidth: 720, minHeight: 480,
@@ -688,11 +602,12 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
});
record.win = win;
record.container = embedded ? new electron.View() : win.contentView;
record.chromeView = null;
record.chromeContents = win.webContents;
record.chromeView = embedded ? new WebContentsView(chromeOptions) : null;
record.chromeContents = record.chromeView?.webContents || win.webContents;
if (embedded) record.container.addChildView(record.chromeView);
record.chromeId = record.chromeContents.id;
windows.set(id, record);
if (!embedded) byChromeId.set(record.chromeId, record);
byChromeId.set(record.chromeId, record);
record.onResize = () => resizeView(record);
record.onBlur = () => closeDownloadsPanel(record);
record.onMaximize = () => send(record);
@@ -703,12 +618,10 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
record.onOwnerClosed = () => closeRecord(record);
win.on("closed", record.onOwnerClosed);
event.sender.once("destroyed", record.onOwnerClosed);
if (!embedded) {
record.chromeContents.setWindowOpenHandler(() => ({ action: "deny" }));
record.chromeContents.on("will-navigate", (navigationEvent) => navigationEvent.preventDefault());
}
record.chromeContents.setWindowOpenHandler(() => ({ action: "deny" }));
record.chromeContents.on("will-navigate", (navigationEvent) => navigationEvent.preventDefault());
try {
if (!embedded) await record.chromeContents.loadFile(path.join(__dirname, "browser.html"));
await record.chromeContents.loadFile(path.join(__dirname, "browser.html"));
if (win.isDestroyed() || windows.get(id) !== record) throw new Error("Browser was closed before it opened");
addTab(record);
if (embedded) applyPanelLayout(record);
@@ -728,19 +641,12 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
record.win.focus();
return record.route === "connected";
};
const getOwned = (event, browserId) => {
if (browserId !== undefined) {
const owned = windows.get(browserId);
if (!owned?.embedded || owned.sourceWebContents !== event.sender
|| BrowserWindow.fromWebContents(event.sender)?.webContents !== event.sender) throw new Error("Untrusted browser control");
return owned;
}
const getOwned = (event) => {
const record = byChromeId.get(event.sender.id);
if (!record || record.chromeContents !== event.sender) throw new Error("Untrusted browser control");
return record;
};
const getBrowserOwned = (event, browserId) => {
if (browserId !== undefined) return getOwned(event, browserId);
const getBrowserOwned = (event) => {
const chrome = byChromeId.get(event.sender.id);
if (chrome && chrome.chromeContents === event.sender) return chrome;
const downloads = byDownloadsId.get(event.sender.id);
@@ -752,18 +658,17 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
ipcMain.handle("netcatty:host-browser:panel-url", openPanelUrl);
ipcMain.handle("netcatty:host-browser:open", open);
ipcMain.handle("netcatty:host-browser:focus", focus);
const getPanelState = (event, browserId) => snapshot(getOwned(event, browserId));
ipcMain.handle("netcatty:host-browser:snapshot", getPanelState);
ipcMain.handle("netcatty:host-browser:downloads:list", (event, browserId) => {
const record = getBrowserOwned(event, browserId);
ipcMain.handle("netcatty:host-browser:snapshot", (event) => snapshot(getOwned(event)));
ipcMain.handle("netcatty:host-browser:downloads:list", (event) => {
const record = getBrowserOwned(event);
return downloadStore.list(record.hostId);
});
ipcMain.handle("netcatty:host-browser:downloads:theme", (event) => {
const record = getBrowserOwned(event);
return { theme: record.theme, themeTokens: record.themeTokens };
});
ipcMain.handle("netcatty:host-browser:downloads:command", async (event, input, browserId) => {
const record = getBrowserOwned(event, browserId);
ipcMain.handle("netcatty:host-browser:downloads:command", async (event, input) => {
const record = getBrowserOwned(event);
const item = downloadStore.get(record.hostId, input?.id);
switch (input?.type) {
case "close": closeDownloadsPanel(record); return true;
@@ -784,18 +689,11 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
notifyDownloads(record);
return true;
case "open":
case "save-as":
case "reveal": {
if (!item || item.status !== "completed" || !item.savePath || !fs.existsSync(item.savePath)) {
throw new Error("File moved or deleted");
}
if (input.type === "save-as") {
const result = await (dialog || require("electron").dialog).showSaveDialog(record.win, {
defaultPath: item.savePath, properties: ["createDirectory", "showOverwriteConfirmation"],
});
if (result.canceled || !result.filePath) return false;
if (path.resolve(result.filePath) !== path.resolve(item.savePath)) await fs.promises.copyFile(item.savePath, result.filePath);
} else if (input.type === "reveal") (shell || require("electron").shell).showItemInFolder(item.savePath);
if (input.type === "reveal") (shell || require("electron").shell).showItemInFolder(item.savePath);
else {
const error = await (shell || require("electron").shell).openPath(item.savePath);
if (error) throw new Error(error);
@@ -805,8 +703,8 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
default: throw new Error("Unknown download action");
}
});
ipcMain.handle("netcatty:host-browser:bookmarks:list", (event, browserId) => {
const record = getOwned(event, browserId);
ipcMain.handle("netcatty:host-browser:bookmarks:list", (event) => {
const record = getOwned(event);
return bookmarkStore.list().filter((item) => item.scope === "all-hosts" || item.hostId === record.hostId)
.map((item) => {
const siteIcon = siteIconStore.get(record.hostId, item.url);
@@ -814,16 +712,16 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
siteIconGlyph: siteIcon?.glyph || null, siteIconColor: siteIcon?.color || null };
});
});
ipcMain.handle("netcatty:host-browser:bookmarks:upsert", (event, input, browserId) => {
const record = getOwned(event, browserId);
ipcMain.handle("netcatty:host-browser:bookmarks:upsert", (event, input) => {
const record = getOwned(event);
if (input?.id) {
const previous = bookmarkStore.list().find((item) => item.id === input.id);
if (previous && previous.scope === "host" && previous.hostId !== record.hostId) throw new Error("Bookmark belongs to another host");
}
return bookmarkStore.upsert({ ...input, hostId: input?.scope === "all-hosts" ? null : record.hostId });
});
ipcMain.handle("netcatty:host-browser:bookmarks:remove", (event, id, browserId) => {
const record = getOwned(event, browserId);
ipcMain.handle("netcatty:host-browser:bookmarks:remove", (event, id) => {
const record = getOwned(event);
const previous = bookmarkStore.list().find((item) => item.id === id);
if (!previous || (previous.scope === "host" && previous.hostId !== record.hostId)) return false;
bookmarkStore.remove(id);
@@ -838,8 +736,8 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
bookmarkStore.apply(items);
return true;
});
const runCommand = async (event, command, browserId) => {
const record = getOwned(event, browserId);
ipcMain.handle("netcatty:host-browser:command", async (event, command) => {
const record = getOwned(event);
if (command?.type?.startsWith("window-") && record.embedded) return snapshot(record);
if (command?.type === "window-close") { record.win.close(); return null; }
const tab = record.tabs.get(command?.tabId || record.activeTabId);
@@ -883,7 +781,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
}
break;
}
case "activate": if (tab) { record.activeTabId = tab.id; displayTab(record); if (record.visible && tab.url) tab.view.webContents.focus(); } break;
case "activate": if (tab) { record.activeTabId = tab.id; displayTab(record); } break;
case "close-tab":
if (tab) {
const tabs = [...record.tabs.values()];
@@ -901,20 +799,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
if (tab) {
tab.url = normalizeBrowserUrl(command.url);
displayTab(record);
const contents = tab.view.webContents;
const contentsId = contents.id;
const url = tab.url;
const navigation = { url, downloading: false };
record.navigations.set(contentsId, navigation);
if (record.visible && record.activeTabId === tab.id && !record.overlayOpen) contents.focus();
try { await contents.loadURL(url); }
catch (error) {
// Chromium rejects loadURL when a navigation becomes a download.
// Response headers identify this request before will-download fires.
if (!navigation.downloading || !["ERR_FAILED", "ERR_ABORTED"].includes(error.code)) throw error;
} finally {
if (record.navigations.get(contentsId) === navigation) record.navigations.delete(contentsId);
}
await tab.view.webContents.loadURL(tab.url);
}
break;
case "back": if (tab && record.route === "connected") tab.view.webContents.navigationHistory?.goBack(); break;
@@ -924,8 +809,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
default: throw new Error("Unknown browser command");
}
return snapshot(record);
};
ipcMain.handle("netcatty:host-browser:command", runCommand);
});
const unsubscribeTunnelStatus = portForwardingBridge.onTunnelStatus((event) => {
for (const record of windows.values()) {
if (record.tunnelId === event.tunnelId && ["inactive", "error"].includes(event.status)) {
@@ -933,10 +817,10 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
}
}
});
return { focus, open, updatePanelLayout, closePanel, openPanelUrl, getPanelState, runCommand, closeAll: () => {
return { focus, open, updatePanelLayout, closePanel, openPanelUrl, closeAll: () => {
unsubscribeTunnelStatus();
for (const record of [...windows.values()]) { closeRecord(record); if (!record.embedded && !record.win.isDestroyed()) record.win.destroy(); }
} };
}
module.exports = { createHostBrowserManager, normalizeBrowserUrl, pdfDownloadHeaders };
module.exports = { createHostBrowserManager, normalizeBrowserUrl };
@@ -4,35 +4,7 @@ const assert = require("node:assert/strict");
const test = require("node:test");
const { readFileSync } = require("node:fs");
const vm = require("node:vm");
const { normalizeBrowserUrl, pdfDownloadHeaders } = require("./hostBrowserManager.cjs");
test("PDF navigations download before the preview can buffer the response", () => {
const headers = { "Content-Type": ["Application/PDF;charset=UTF-8"], "Content-Length": ["8933895"] };
for (const resourceType of ["mainFrame", "subFrame"]) {
assert.deepEqual(pdfDownloadHeaders({ resourceType, statusCode: 200, responseHeaders: headers }), {
...headers, "Content-Disposition": ["attachment"],
});
}
assert.equal(headers["Content-Disposition"], undefined, "the original response headers are not mutated");
});
test("PDF downloads preserve the site's localized suggested filename", () => {
const filename = "; filename=report.pdf; filename*=UTF-8''%E6%8A%A5%E5%91%8A.pdf";
const headers = { "content-type": ["application/pdf"], "content-disposition": [`inline${filename}`] };
assert.deepEqual(pdfDownloadHeaders({ resourceType: "mainFrame", statusCode: 200, responseHeaders: headers }), {
...headers, "content-disposition": [`attachment${filename}`],
});
});
test("PDF policy leaves webpage requests, failed responses and explicit downloads alone", () => {
const responseHeaders = { "content-type": ["application/pdf"] };
const details = { resourceType: "mainFrame", statusCode: 200, responseHeaders };
for (const resourceType of ["xhr", "object", "other", "script", "image"]) assert.equal(pdfDownloadHeaders({ ...details, resourceType }), undefined);
for (const statusCode of [302, 404, 500]) assert.equal(pdfDownloadHeaders({ ...details, statusCode }), undefined);
for (const mime of ["text/html", "image/png", "application/octet-stream"]) assert.equal(pdfDownloadHeaders({ ...details, responseHeaders: { "content-type": [mime] } }), undefined);
assert.equal(pdfDownloadHeaders({ ...details, responseHeaders: { ...responseHeaders, "content-disposition": ["attachment; filename=report.pdf"] } }), undefined);
assert.equal(pdfDownloadHeaders({ ...details, responseHeaders: undefined }), undefined);
});
const { normalizeBrowserUrl } = require("./hostBrowserManager.cjs");
test("browser addresses accept bare localhost and domain ports", () => {
assert.equal(normalizeBrowserUrl("localhost:3000"), "http://localhost:3000/");
-12
View File
@@ -1341,18 +1341,6 @@ function createPreloadApi(ctx) {
focusHostBrowser: (hostId) => ipcRenderer.invoke("netcatty:host-browser:focus", hostId),
setHostBrowserPanelLayout: (options) => ipcRenderer.invoke("netcatty:host-browser:panel-layout", options),
closeHostBrowserPanel: (browserId) => ipcRenderer.invoke("netcatty:host-browser:panel-close", browserId),
getHostBrowserPanelState: (browserId) => ipcRenderer.invoke("netcatty:host-browser:snapshot", browserId),
commandHostBrowserPanel: (browserId, command) => ipcRenderer.invoke("netcatty:host-browser:command", command, browserId),
listHostBrowserPanelBookmarks: (browserId) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:list", browserId),
saveHostBrowserPanelBookmark: (browserId, bookmark) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:upsert", bookmark, browserId),
removeHostBrowserPanelBookmark: (browserId, id) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:remove", id, browserId),
listHostBrowserPanelDownloads: (browserId) => ipcRenderer.invoke("netcatty:host-browser:downloads:list", browserId),
commandHostBrowserPanelDownload: (browserId, command) => ipcRenderer.invoke("netcatty:host-browser:downloads:command", command, browserId),
onHostBrowserPanelEvent: (callback) => {
const listener = (_event, value) => callback(value);
ipcRenderer.on("netcatty:host-browser:panel-event", listener);
return () => ipcRenderer.removeListener("netcatty:host-browser:panel-event", listener);
},
openHostBrowserUrl: (options) => ipcRenderer.invoke("netcatty:host-browser:panel-url", options),
requestHostBrowserPanel: (request) => ipcRenderer.invoke("netcatty:host-browser:request-panel", request),
onHostBrowserPanelRequest: (callback) => {
-3
View File
@@ -321,6 +321,3 @@ export const STORAGE_KEY_DEBUG_UPDATE_DEMO = 'debug.updateDemo';
// Portable monitor configuration. Runtime samples never use storage.
export const STORAGE_KEY_MONITOR_PANELS = 'netcatty_monitor_panels_v1';
export const STORAGE_KEY_MONITOR_TABLE_COLUMNS = 'netcatty_monitor_table_columns_v1';
export const STORAGE_KEY_MONITOR_HIDDEN_COLUMNS = "netcatty_monitor_hidden_columns_v1";
@@ -1,52 +0,0 @@
import type { BrowserBookmark } from '../../domain/browserBookmark';
import type { HostBrowserCommand, HostBrowserPanelEvent } from '../../domain/hostBrowser';
import { HostBrowserIconError } from '../../domain/hostBrowser';
import { netcattyBridge } from './netcattyBridge';
export function hostBrowserClient(browserId: string) {
const bridge = netcattyBridge.require();
if (!bridge.getHostBrowserPanelState || !bridge.commandHostBrowserPanel) {
throw new Error('Restart Netcatty to load the updated browser controls');
}
return {
state: () => bridge.getHostBrowserPanelState!(browserId),
command: (command: HostBrowserCommand) => bridge.commandHostBrowserPanel!(browserId, command),
bookmarks: () => bridge.listHostBrowserPanelBookmarks!(browserId),
saveBookmark: (bookmark: Partial<BrowserBookmark>) => bridge.saveHostBrowserPanelBookmark!(browserId, bookmark),
removeBookmark: (id: string) => bridge.removeHostBrowserPanelBookmark!(browserId, id),
downloads: () => bridge.listHostBrowserPanelDownloads!(browserId),
downloadCommand: (command: Parameters<NonNullable<NetcattyBridge['commandHostBrowserPanelDownload']>>[1]) =>
bridge.commandHostBrowserPanelDownload!(browserId, command),
subscribe: (callback: (event: HostBrowserPanelEvent) => void) => bridge.onHostBrowserPanelEvent?.((event) => {
if (event.browserId === browserId) callback(event);
}) ?? (() => {}),
};
}
export async function readHostBrowserIcon(file: File): Promise<string> {
if (!new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/x-icon', 'image/vnd.microsoft.icon']).has(file.type)
) throw new HostBrowserIconError('file-type');
if (file.size > 5 * 1024 * 1024) throw new HostBrowserIconError('file-size');
const source = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new HostBrowserIconError('decode'));
reader.readAsDataURL(file);
});
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const element = new Image();
element.onload = () => resolve(element);
element.onerror = () => reject(new HostBrowserIconError('decode'));
element.src = source;
});
if (!image.naturalWidth || !image.naturalHeight) throw new HostBrowserIconError('decode');
if (file.size <= 64 * 1024 && Math.max(image.naturalWidth, image.naturalHeight) <= 128) return source;
const canvas = document.createElement('canvas');
const scale = Math.min(1, 96 / Math.max(image.naturalWidth, image.naturalHeight));
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
const context = canvas.getContext('2d');
if (!context) throw new HostBrowserIconError('decode');
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png');
}
-11
View File
@@ -1,11 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./monitor-panels.tsx"></script>
</body>
</html>
-218
View File
@@ -1,218 +0,0 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { I18nProvider } from "../../application/i18n/I18nProvider";
import { writeMonitorPanels } from "../../application/state/monitorPanelsStorage";
import {
monitorActions,
publishMonitorSnapshot,
} from "../../application/state/monitorRuntimeStore";
import { MonitorPanels } from "../../components/vault/MonitorPanels";
import type { Host } from "../../domain/models";
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
import "../../index.css";
const tokens = {
background: "210 25% 18%",
foreground: "210 20% 90%",
card: "210 23% 21%",
secondary: "210 20% 24%",
muted: "210 20% 22%",
"muted-foreground": "210 15% 65%",
border: "210 20% 30%",
primary: "170 70% 42%",
"primary-foreground": "210 20% 12%",
popover: "210 25% 20%",
"popover-foreground": "210 20% 90%",
destructive: "0 70% 60%",
input: "210 20% 30%",
ring: "170 70% 42%",
};
for (const [key, value] of Object.entries(tokens))
document.documentElement.style.setProperty(`--${key}`, value);
document.documentElement.classList.add("dark");
document.documentElement.style.setProperty("--radius", "8px");
const root = createRoot(document.getElementById("root")!);
let connections: string[] = [];
let retries: {
id: string;
credentials?: { username: string; password: string };
}[] = [];
monitorActions.retry = (id, credentials) => {
retries.push({ id, credentials });
};
let revision = 0;
let latestSnapshot: MonitorSnapshot;
function seed(
count = 2,
view: "list" | "charts" = "charts",
locale = "zh-CN",
pointCount = 12,
) {
const now = Date.now();
const hosts: Host[] = Array.from({ length: count }, (_, i) => ({
id: `host-${i}`,
label: `Production server ${String(i + 1).padStart(4, "0")}`,
hostname: `10.24.${Math.floor(i / 250)}.${(i % 250) + 1}`,
username: "fixture",
port: 22,
os: "linux",
distro: "ubuntu",
group: "Production",
tags:
i === 1
? []
: [
"production",
"database",
"critical",
"east-region",
"long-tag-value-for-overflow",
],
notes:
i === 1
? ""
: "Primary database cluster. Contact the on-call engineer before restarting.\nMaintenance window: Sunday 02:00–04:00.",
createdAt: 1,
}));
writeMonitorPanels([
{
id: "fixture",
name: "Fleet overview",
description: "",
hostIds: hosts.map((h) => h.id),
view,
interval: 5,
range: 15,
sort: "manual",
},
]);
const snapshot: MonitorSnapshot = {
historyMode: "replace",
runtimeId: "monitor-ui-fixture",
epoch: now,
takenAt: now,
running: ["fixture"],
count: 1,
hostCount: count,
activeSessions: [],
startedAt: { fixture: now - Math.max(60000, pointCount * 1000) },
hosts: Object.fromEntries(
hosts.map((host, i) => [
host.id,
{
historyId: host.id,
status: "live",
at: now,
stats: {
cpuPerCore: [],
memFree: 12288,
memBuffers: 0,
memCached: 0,
swapTotal: 0,
swapUsed: 0,
topProcesses: [],
netInterfaces: [],
cpu: 23 + (i % 60),
cpuCores: 8,
memUsed: 4096,
memTotal: 16384,
diskPercent: 35,
diskUsed: 35,
diskTotal: 100,
disks: Array.from({ length: i === 2 ? 10 : 2 }, (_, j) => ({
mountPoint: j ? `/data-${j}` : "/",
used: 35,
total: 100 + j * 50,
percent: 35,
})),
netRxSpeed: 240000,
netTxSpeed: 72000,
networkReady: true,
latencyMs:
i === 1 || i === 5
? null
: i === 2
? 180
: i === 3
? 450
: i === 4
? 0
: 12 + (i % 30),
responseLatencyMs: i === 5 ? 145 : null,
gpu: i === 0 ? 45 : i === 2 ? 0 : null,
gpuName:
i === 0
? "NVIDIA GeForce RTX 4090"
: i === 2
? "NVIDIA Tesla T4"
: null,
gpuMemUsed: i === 0 ? 8192 : i === 2 ? 0 : null,
gpuMemTotal: i === 0 ? 24576 : i === 2 ? 16384 : null,
},
points: Array.from({ length: pointCount }, (_, j) => ({
at: now - (pointCount - 1 - j) * (pointCount === 12 ? 5000 : 1000),
cpu: 20 + ((i + j) % 40),
memory: 25,
disk: 35,
rx: 200000 + j * 1000,
tx: 70000 + j * 800,
})),
},
]),
),
};
latestSnapshot = snapshot;
publishMonitorSnapshot(snapshot);
connections = [];
retries = [];
root.render(
<I18nProvider locale={locale}>
<div className="flex h-screen min-w-0 bg-background text-foreground">
<MonitorPanels
key={++revision}
hosts={hosts}
customGroups={["Production"]}
onConnect={(host) => {
connections.push(host.id);
}}
/>
</div>
</I18nProvider>,
);
}
Object.assign(window, {
monitorFixture: {
seed,
connections: () => connections,
retries: () => retries,
status: (id: string, status: string, age = 0) => {
latestSnapshot = {
...latestSnapshot,
takenAt: Date.now(),
hosts: {
...latestSnapshot.hosts,
[id]: {
...latestSnapshot.hosts[id],
at: Date.now() - age,
status: status as MonitorSnapshot["hosts"][string]["status"],
},
},
};
publishMonitorSnapshot(latestSnapshot);
},
refresh: () => {
latestSnapshot = {
...latestSnapshot,
takenAt: Date.now(),
hosts: Object.fromEntries(
Object.entries(latestSnapshot.hosts).map(([id, state]) => [
id,
{ ...state, at: Date.now(), points: [...state.points] },
]),
),
};
publishMonitorSnapshot(latestSnapshot);
},
},
});
seed(Number(new URLSearchParams(location.search).get("count")) || 2);
-840
View File
@@ -1,840 +0,0 @@
"use strict";
// Real production monitoring components, isolated storage and deterministic readings.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { app, BrowserWindow } = require("electron");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const profile = fs.mkdtempSync(temp.getTempFilePath("monitor-ui-"));
app.setPath("userData", profile);
let owner, vite;
const errors = [];
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const run = (code) => owner.webContents.executeJavaScript(code);
async function until(read, label) {
for (let i = 0; i < 150; i++) {
if (await read()) return;
await wait(30);
}
assert.ok(await read(), label);
}
async function click(selector) {
const rect = await run(
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e?.scrollIntoView({block:'nearest'});const r=e?.getBoundingClientRect();return r&&{x:r.x+r.width/2,y:r.y+r.height/2}})()`,
);
assert.ok(rect, selector);
owner.webContents.focus();
for (const type of ["mouseDown", "mouseUp"])
owner.webContents.sendInputEvent({
type,
button: "left",
clickCount: 1,
x: Math.round(rect.x),
y: Math.round(rect.y),
});
await run(
"new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))",
);
}
const watchdog = setTimeout(() => {
console.error("Monitoring UI acceptance timed out");
app.exit(1);
}, 90000);
async function main() {
const { createServer } = await import("vite");
vite = await createServer({
root: path.resolve(__dirname, ".."),
server: { host: "127.0.0.1", port: 0 },
logLevel: "error",
});
await vite.listen();
owner = new BrowserWindow({ width: 1280, height: 900, show: true });
owner.webContents.on("console-message", (event) => {
if (event.level === "error" || event.level >= 3) errors.push(event.message);
});
await owner.loadURL(
`http://127.0.0.1:${vite.httpServer.address().port}/scripts/fixtures/monitor-panels.html`,
);
await until(
() => run('document.querySelectorAll("[data-monitor-host]").length > 0'),
"monitoring renders",
);
const screenshot = async (name) => {
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
await wait(200);
await run(
"document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))",
);
fs.writeFileSync(
path.join(profile, name),
(await owner.webContents.capturePage()).toPNG(),
);
};
const count = () =>
run('document.querySelectorAll("[data-monitor-host]").length');
const scroll = (selector) =>
run(
`(()=>{const e=document.querySelector('[data-section="vault-monitor-list"]');e.scrollTop=${selector};})()`,
);
const hover = async (selector) => {
owner.focus();
owner.webContents.focus();
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
await run(
"new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))",
);
const point = await run(
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.scrollIntoView({block:'nearest',inline:'nearest'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`,
);
owner.webContents.sendInputEvent({
type: "mouseMove",
x: Math.round(point.x),
y: Math.round(point.y),
});
// Tooltip grace areas update on movement; model the following mouse event
// that a real pointer produces rather than teleporting between triggers.
await run("new Promise(r=>requestAnimationFrame(r))");
owner.webContents.sendInputEvent({
type: "mouseMove",
x: Math.round(point.x + 1),
y: Math.round(point.y),
});
};
const checkActionContrast = async (view) => {
const keys = ["accent", "accent-foreground"];
const saved = await run(
`Object.fromEntries(${JSON.stringify(["accent", "accent-foreground"])}.map(k=>[k,document.documentElement.style.getPropertyValue('--'+k)]))`,
);
await run(
`document.documentElement.style.setProperty('--accent','170 70% 42%');document.documentElement.style.setProperty('--accent-foreground','210 20% 12%')`,
);
await hover("[data-monitor-terminal=host-0]");
await wait(200);
const colors = await run(
`(()=>{const e=document.querySelector('[data-monitor-terminal=host-0]');return {background:getComputedStyle(e).backgroundColor,icon:getComputedStyle(e.querySelector('svg')).color}})()`,
);
const luminance = (color) =>
color
.match(/[\d.]+/g)
.slice(0, 3)
.map(Number)
.map((v) => v / 255)
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
.reduce((n, v, i) => n + v * [0.2126, 0.7152, 0.0722][i], 0);
const a = luminance(colors.background),
b = luminance(colors.icon);
assert.ok(
(Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) >= 3,
`${view} terminal icon stays visible on accent hover: ${JSON.stringify(colors)}`,
);
fs.writeFileSync(
path.join(profile, `monitor-${view}-action-hover.png`),
(await owner.webContents.capturePage()).toPNG(),
);
for (const key of keys)
await run(
`document.documentElement.style.setProperty('--'+${JSON.stringify(key)},${JSON.stringify(saved[key])})`,
);
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
};
app.focus({ steal: true });
owner.focus();
owner.webContents.focus();
assert.deepEqual(
await run(
'Array.from(document.querySelectorAll("[data-monitor-host]")).map(e=>e.getBoundingClientRect().height)',
),
[368, 368],
);
assert.equal(
await run(
'document.querySelector("[data-monitor-host=host-0] [data-monitor-latency]").getAttribute("aria-label")',
),
"延迟: 12 ms",
);
assert.equal(
await run(
'document.querySelector("[data-monitor-host=host-1] [data-monitor-latency]").getAttribute("aria-label")',
),
"延迟: --",
);
assert.equal(
await run('document.body.textContent.includes("读数只保留在内存")'),
false,
);
await hover("[data-monitor-host=host-0] [data-monitor-notes]");
await until(
() =>
run(
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("Maintenance window"))',
),
"full notes appear on hover",
);
await screenshot("notes-tooltip.png");
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
await checkActionContrast("charts");
await click("[data-monitor-terminal=host-0]");
assert.deepEqual(await run("window.monitorFixture.connections()"), [
"host-0",
]);
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
"terminal action does not open details",
);
await click("[data-monitor-host=host-0] > button:first-child");
await until(
() => run('!!document.querySelector("[data-monitor-gpu]")'),
"GPU details appear",
);
const gpu = await run(
'document.querySelector("[data-monitor-gpu]").textContent',
);
for (const expected of [
"NVIDIA GeForce RTX 4090",
"45.0%",
"8.0 GiB",
"24.0 GiB",
])
assert.ok(gpu.includes(expected), expected);
await screenshot("monitor-gpu.png");
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
await click("[data-monitor-host=host-1] > button:first-child");
await until(
() =>
run('!!document.querySelector("[data-section=vault-monitor-detail]")'),
"non-GPU details open",
);
assert.equal(
await run('!!document.querySelector("[data-monitor-gpu]")'),
false,
"hosts without GPU have no empty GPU section",
);
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
await run("window.monitorFixture.seed(6,'charts')");
await until(
() => run('document.querySelectorAll("[data-monitor-host]").length===6'),
"signal examples render",
);
assert.deepEqual(
await run(
'Array.from(document.querySelectorAll("[data-monitor-latency]")).map(e=>Number(e.dataset.monitorSignalBars))',
),
[4, 0, 2, 1, 4, 2],
);
assert.ok(
await run(
`(()=>{const row=document.querySelector('[data-monitor-host=host-0]');const a=row.querySelector('[data-monitor-latency]').getBoundingClientRect();const b=row.querySelector('[data-monitor-tags]').getBoundingClientRect();return Math.abs((a.top+a.bottom)/2-(b.top+b.bottom)/2)<1})()`,
),
"latency and tags are vertically centered",
);
await hover("[data-monitor-host=host-2] [data-monitor-latency]");
await until(
() =>
run(
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("180 ms"))',
),
"signal tooltip has exact latency",
);
await hover("[data-monitor-host=host-5] [data-monitor-latency]");
await until(
() =>
run(
'Array.from(document.querySelectorAll("[role=tooltip]")).some(e=>e.textContent.includes("响应延迟(含远端处理): 145 ms"))',
),
"disabled keepalive still shows response latency",
);
await screenshot("monitor-signals.png");
await click("[data-monitor-host=host-2] > button:first-child");
await until(
() => run('!!document.querySelector("[data-monitor-gpu]")'),
"idle GPU details open",
);
const idleGpu = await run(
'document.querySelector("[data-monitor-gpu]").textContent',
);
assert.ok(
idleGpu.includes("0.0%") && idleGpu.includes("0 MiB"),
"zero GPU readings remain visible",
);
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
await run("window.monitorFixture.seed(1200,'charts')");
await until(
async () => (await count()) > 2 && (await count()) < 80,
"charts render a bounded visible subset",
);
assert.equal(
await run('document.querySelectorAll("[data-monitor-terminal]").length'),
await count(),
);
assert.ok(
await run(
'Array.from(document.querySelectorAll("[data-monitor-host]")).every(e=>e.getBoundingClientRect().height===368)',
),
);
await scroll("e.scrollHeight");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-1199]")'),
"last card can be reached",
);
await click("[data-monitor-terminal=host-1199]");
assert.deepEqual(
await run("window.monitorFixture.connections()"),
["host-1199"],
"recycled card opens the correct host",
);
await click("button:has(.lucide-list)");
await until(
() => run('!!document.querySelector("table")'),
"list view opens",
);
assert.ok((await count()) < 50, "list renders only visible rows");
for (const position of [0, 450, 100000]) {
await run(
`document.querySelector('[data-section=vault-monitor-list]').scrollLeft=${position}`,
);
await run("new Promise(r=>requestAnimationFrame(r))");
assert.ok(
await run(
`(()=>{const viewport=document.querySelector('[data-section=vault-monitor-list]');const action=document.querySelector('tbody tr td:last-child');return window.innerWidth-viewport.getBoundingClientRect().right>=15 && window.innerWidth-action.getBoundingClientRect().right>=15})()`,
),
"sticky actions retain the right page gutter at every horizontal scroll position",
);
const geometry = await run(
`(()=>{const r=document.querySelector('tbody tr td:last-child').getBoundingClientRect();return {width:innerWidth,height:innerHeight,right:r.right,top:r.top,bottom:r.bottom}})()`,
);
const captured = await owner.webContents.capturePage();
const bitmap = captured.toBitmap();
const scale = Math.sqrt(
bitmap.length / (4 * geometry.width * geometry.height),
);
const pixelWidth = Math.round(geometry.width * scale);
const pixel = (x, y) => {
const start =
4 * (Math.floor(y * scale) * pixelWidth + Math.floor(x * scale));
return [...bitmap.subarray(start, start + 4)];
};
const backdrop = pixel(geometry.width - 4, geometry.top + 1);
assert.deepEqual(
pixel(geometry.right - 1, geometry.top + 1),
backdrop,
`top-right corner stays visibly rounded at scroll ${position}`,
);
assert.deepEqual(
pixel(geometry.right - 1, geometry.bottom - 2),
backdrop,
`bottom-right corner stays visibly rounded at scroll ${position}`,
);
assert.notDeepEqual(
pixel(geometry.right - 2, (geometry.top + geometry.bottom) / 2),
backdrop,
"rounded action still paints the row background",
);
if (position === 0) await screenshot("monitor-rounded-row-start.png");
}
await scroll("0");
await checkActionContrast("list");
const columnOrder = await run(
'Array.from(document.querySelectorAll("th")).map(e=>e.dataset.monitorColumn)',
);
assert.deepEqual(columnOrder.slice(-2), ["status", "terminal"]);
assert.equal(
await run(
`Array.from(document.querySelectorAll('th [role=separator]')).every(e=>getComputedStyle(e,'::after').backgroundColor==='rgba(0, 0, 0, 0)')`,
),
true,
"column dividers are hidden at rest",
);
assert.equal(
await run('document.querySelectorAll("th [role=separator]").length'),
11,
"every column can be resized",
);
await scroll("0");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-2]")'),
"variable-height row is rendered",
);
await until(
() =>
run(
'document.querySelector("[data-monitor-host=host-2]").getBoundingClientRect().height>150',
),
"all partitions determine row height",
);
assert.ok(
await run(
`(()=>{const rows=[...document.querySelectorAll('tbody tr')];return rows.every((r,i)=>!i||r.getBoundingClientRect().top>=rows[i-1].getBoundingClientRect().bottom)})()`,
),
"variable-height rows never overlap",
);
await click("[data-monitor-terminal=host-0]");
assert.deepEqual(await run("window.monitorFixture.connections()"), [
"host-1199",
"host-0",
]);
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
);
await run(
"document.querySelector('[data-section=vault-monitor-list]').scrollLeft=0",
);
const widths = {};
for (const key of [
"host",
"status",
"latency",
"tags",
"notes",
"cpu",
"memory",
"disks",
"network",
"updated",
"terminal",
]) {
const selector = `[data-monitor-column=${key}] [role=separator]`;
const point = await run(
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.scrollIntoView({block:'nearest',inline:'center'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2,width:Number(e.getAttribute('aria-valuenow'))}})()`,
);
owner.webContents.sendInputEvent({
type: "mouseDown",
button: "left",
clickCount: 1,
x: Math.round(point.x),
y: Math.round(point.y),
});
owner.webContents.sendInputEvent({
type: "mouseMove",
x: Math.round(point.x + 28),
y: Math.round(point.y),
});
owner.webContents.sendInputEvent({
type: "mouseUp",
button: "left",
clickCount: 1,
x: Math.round(point.x + 28),
y: Math.round(point.y),
});
await until(
() =>
run(
`Number(document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-valuenow'))>=${point.width + 27}`,
),
`${key} width responds to dragging`,
);
widths[key] = await run(
`Number(document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-valuenow'))`,
);
assert.ok(
await run(
`(()=>{const h=document.querySelector('[data-monitor-column=${key}]');const i=[...h.parentElement.children].indexOf(h);const cell=document.querySelector('tbody tr').children[i];return Math.abs(h.getBoundingClientRect().width-cell.getBoundingClientRect().width)<1})()`,
),
"header and body column widths stay aligned",
);
}
await run(
"document.querySelector('[data-monitor-column=host] [role=separator]').focus()",
);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Right" });
widths.host += 8;
await until(
() =>
run(
`Number(document.querySelector('[data-monitor-column=host] [role=separator]').getAttribute('aria-valuenow'))===${widths.host}`,
),
"column size can also be adjusted by keyboard",
);
await run("window.monitorFixture.seed(1200,'list')");
await until(() => run('!!document.querySelector("table")'), "list remounts");
for (const [key, width] of Object.entries(widths))
assert.equal(
await run(
`Number(document.querySelector('[data-monitor-column=${key}] [role=separator]').getAttribute('aria-valuenow'))`,
),
width,
"column widths survive remounting",
);
await scroll("e.scrollHeight");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-1199]")'),
"last table row can be reached",
);
await click("[data-monitor-terminal=host-1199]");
assert.deepEqual(
await run("window.monitorFixture.connections()"),
["host-1199"],
"recycled row opens the correct host",
);
await run(
`(()=>{const e=document.querySelector('input[placeholder="搜索主机、分组、标签或备注"]');Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(e,'1199');e.dispatchEvent(new Event('input',{bubbles:true}));})()`,
);
await until(
async () => (await count()) === 1,
"filtering at the bottom does not leave a blank virtual viewport",
);
assert.ok(
await run('document.body.textContent.includes("Production server 1199")'),
);
await run("window.monitorFixture.seed(40,'list')");
await until(
() => run('document.querySelectorAll("tbody tr").length>2'),
"unfiltered list restores",
);
await screenshot("monitor-list.png");
for (const width of [460, 1000]) {
owner.setSize(width, 900);
await wait(100);
await run("window.monitorFixture.seed(40,'charts')");
await until(
() => run('!!document.querySelector("[data-monitor-host]")'),
"cards fit resized viewport",
);
assert.ok(
await run(
'Array.from(document.querySelectorAll("[data-monitor-host]")).every(e=>e.getBoundingClientRect().height===368)',
),
"card height is fixed at all widths",
);
assert.ok(
await run(
"(()=>{const e=document.querySelector('[data-section=vault-monitor-list]');return e.scrollWidth<=e.clientWidth+1})()",
),
"cards do not overflow horizontally",
);
}
await screenshot("monitor-charts.png");
for (const locale of ["en", "zh-TW", "es", "ru"]) {
await run(
`window.monitorFixture.seed(40,'list',${JSON.stringify(locale)})`,
);
await until(
() => run('!!document.querySelector("table")'),
"translated table renders",
);
assert.equal(
await run('document.body.textContent.includes("monitor.")'),
false,
"no missing translation keys",
);
}
const openColumnMenu = async () => {
const point = await run(
`(()=>{document.querySelector('[data-section=vault-monitor-list]').scrollLeft=0;const r=document.querySelector('thead').getBoundingClientRect();return {x:Math.round(r.left+40),y:Math.round(r.top+20)}})()`,
);
for (const type of ["mouseDown", "mouseUp"])
owner.webContents.sendInputEvent({
type,
button: "right",
clickCount: 1,
...point,
});
await until(
() => run('!!document.querySelector("[data-monitor-column-menu]")'),
"header right-click opens column choices",
);
};
await run("window.monitorFixture.seed(8,'list','zh-CN')");
await until(
() => run('document.querySelectorAll("th").length===11'),
"all columns start visible",
);
await openColumnMenu();
assert.equal(
await run(
`['host','terminal'].every(k=>{const e=document.querySelector('[data-monitor-toggle-column='+k+']');return e.getAttribute('aria-checked')==='true'&&e.getAttribute('aria-disabled')==='true'})`,
),
true,
"host and terminal cannot be hidden",
);
const optional = columnOrder.filter((k) => !["host", "terminal"].includes(k));
for (const key of optional)
await click(`[data-monitor-toggle-column=${key}]`);
assert.deepEqual(
await run(
'[...document.querySelectorAll("th")].map(e=>e.dataset.monitorColumn)',
),
["host", "terminal"],
);
assert.ok(
await run(
'[...document.querySelectorAll("tbody tr")].every(e=>e.children.length===2)',
),
"hidden headings and row cells stay aligned",
);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Escape" });
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Escape" });
await run("window.monitorFixture.seed(8,'list','zh-CN')");
await until(
() => run('document.querySelectorAll("th").length===2'),
"column visibility survives reopening",
);
const hostWidthBefore = await run(
'document.querySelector("tbody tr td").getBoundingClientRect().width',
);
await run(
'document.querySelector("[data-monitor-column=host] [role=separator]").focus()',
);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Right" });
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Right" });
await until(
() =>
run(
`document.querySelector('tbody tr td').getBoundingClientRect().width===${hostWidthBefore + 8}`,
),
"host visibly resizes after other columns are hidden",
);
await openColumnMenu();
for (const key of optional)
await click(`[data-monitor-toggle-column=${key}]`);
owner.webContents.sendInputEvent({ type: "keyDown", keyCode: "Escape" });
owner.webContents.sendInputEvent({ type: "keyUp", keyCode: "Escape" });
await until(
() => run('document.querySelectorAll("th").length===11'),
"hidden columns can be restored",
);
for (const view of ["list", "charts"]) {
await run(`window.monitorFixture.seed(3,'${view}','zh-CN')`);
await until(
() => run('!!document.querySelector("[data-monitor-host=host-0]")'),
"status fixtures render",
);
for (const status of [
"login",
"error",
"disconnected",
"unsupported",
"queued",
"connecting",
"limit",
]) {
await run(`window.monitorFixture.status('host-0','${status}')`);
await until(
() =>
run(
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='true'`,
),
"unavailable host has disabled detail action",
);
assert.equal(
await run(
"getComputedStyle(document.querySelector('[data-monitor-host=host-0]')).opacity",
),
"0.5",
"unavailable host is dimmed",
);
await click("[data-monitor-host=host-0] button");
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
"unavailable host does not open details",
);
}
await run("window.monitorFixture.status('host-0','live',60000)");
await until(
() =>
run(
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='true'`,
),
"stale readings cannot open details",
);
await click("[data-monitor-host=host-0] button");
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
"stale host stays unavailable",
);
await click("[data-monitor-terminal=host-0]");
assert.deepEqual(
await run("window.monitorFixture.connections()"),
["host-0"],
"login host can still open a terminal",
);
await run("window.monitorFixture.status('host-0','login')");
const recoveryPoint = await run(
`(()=>{const e=document.querySelector('[data-monitor-host=host-0]');e.scrollIntoView({block:'nearest',inline:'nearest'});const r=e.getBoundingClientRect();return {x:Math.round(r.left+30),y:Math.round(r.top+20)}})()`,
);
for (const type of ["mouseDown", "mouseUp"])
owner.webContents.sendInputEvent({
type,
button: "right",
clickCount: 1,
...recoveryPoint,
});
await until(
() => run('!!document.querySelector("[data-monitor-retry=host-0]")'),
"unavailable host context menu offers recovery",
);
await click("[data-monitor-retry=host-0]");
await until(
() => run('!!document.querySelector("#monitor-login-password")'),
"recovery dialog opens separately from details",
);
assert.equal(
await run(
'!!document.querySelector("[data-section=vault-monitor-detail]")',
),
false,
);
await click("#monitor-login-password");
owner.webContents.insertText("fixture-password");
await click("[role=dialog] button[type=submit]");
await until(
() => run('!document.querySelector("#monitor-login-password")'),
"recovery clears password inputs",
);
assert.deepEqual(
await run("window.monitorFixture.retries()"),
[
{
id: "host-0",
credentials: { username: "fixture", password: "fixture-password" },
},
],
"recovery forwards one-time credentials to the existing monitor retry path",
);
await run("window.monitorFixture.status('host-0','live')");
await until(
() =>
run(
`document.querySelector('[data-monitor-host=host-0] button').getAttribute('aria-disabled')==='false'`,
),
"recovered host enables details",
);
await click("[data-monitor-host=host-0] button");
await until(
() =>
run('!!document.querySelector("[data-section=vault-monitor-detail]")'),
"live host opens details",
);
await run("window.monitorFixture.status('host-0','login')");
await until(
() =>
run('!document.querySelector("[data-section=vault-monitor-detail]")'),
"unavailable details close immediately",
);
}
owner.setSize(1280, 650);
await run("window.monitorFixture.seed(3,'charts','zh-CN',1800)");
await until(
() => run('!!document.querySelector("[data-monitor-host=host-0]")'),
"long monitoring history renders",
);
await click("[data-monitor-host=host-0] button");
await until(
() => run('!!document.querySelector("[data-aside-scroll-viewport]")'),
"native monitoring detail scroll opens",
);
await until(
() =>
run(
`document.querySelector('[data-section=vault-monitor-detail]').getAnimations().every(a=>a.playState==='finished')`,
),
"sidebar opening animation finishes before wheel input",
);
const wheelPoint = await run(
`(()=>{const v=document.querySelector('[data-aside-scroll-viewport]');const r=v.getBoundingClientRect();window.monitorRefreshTimer=setInterval(()=>window.monitorFixture.refresh(),200);window.monitorWheelProbe={events:0,blocking:0};window.monitorWheelListener=e=>{window.monitorWheelProbe.events++;if(e.cancelable)window.monitorWheelProbe.blocking++;};document.addEventListener('wheel',window.monitorWheelListener,{passive:true});return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+180)}})()`,
);
for (let i = 0; i < 60; i++) {
owner.webContents.sendInputEvent({
type: "mouseWheel",
...wheelPoint,
deltaY: -12,
deltaX: 0,
canScroll: true,
});
await wait(17);
}
await until(
() =>
run(
"document.querySelector('[data-aside-scroll-viewport]').scrollTop>200",
),
"wheel reaches lower detail sections with a half-hour history",
);
const wheelResult = await run("window.monitorWheelProbe");
assert.ok(
wheelResult.events > 40,
"real wheel input reached monitoring details",
);
assert.equal(
wheelResult.blocking,
0,
"detail wheel input does not wait for a cancellable handler",
);
assert.equal(
await run(
"document.querySelector('[data-section=vault-monitor-list]').scrollTop",
),
0,
"sidebar scroll leaves host collection in place",
);
await screenshot("monitor-native-detail-scroll.png");
await run(
"clearInterval(window.monitorRefreshTimer);document.removeEventListener('wheel',window.monitorWheelListener)",
);
await click("[data-section=vault-monitor-detail] button:has(.lucide-x)");
owner.setSize(1280, 900);
await run(
`(()=>{document.documentElement.classList.remove('dark');for(const [k,v] of Object.entries({background:'0 0% 100%',foreground:'210 20% 15%',card:'210 20% 97%',secondary:'210 20% 94%',muted:'210 20% 95%','muted-foreground':'210 10% 45%',border:'210 20% 88%',popover:'0 0% 100%','popover-foreground':'210 20% 15%'}))document.documentElement.style.setProperty('--'+k,v)})()`,
);
await run("window.monitorFixture.seed(40,'charts','zh-CN')");
await until(
() => run('!!document.querySelector("[data-monitor-host]")'),
"light cards render",
);
await screenshot("monitor-light.png");
assert.deepEqual(errors, [], "no renderer errors");
console.log(
JSON.stringify({
ok: true,
virtualHosts: 1200,
fixedCardHeight: 368,
terminalActions: true,
notesTooltip: true,
latencySignals: true,
gpuDetails: true,
statusBeforeAction: true,
allColumnsResize: true,
persistedWidths: true,
visibleColumns: true,
unavailableHosts: true,
nativeDetailScroll: true,
variableRowHeights: true,
filterAfterScroll: true,
locales: 5,
profile,
}),
);
}
app
.whenReady()
.then(main)
.catch(async (e) => {
console.error(e);
process.exitCode = 1;
if (owner && !owner.isDestroyed())
fs.writeFileSync(
path.join(profile, "failure.png"),
(await owner.webContents.capturePage()).toPNG(),
);
})
.finally(async () => {
clearTimeout(watchdog);
owner?.destroy();
await vite?.close();
app.exit(process.exitCode || 0);
});
-1
View File
@@ -327,7 +327,6 @@ declare global {
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
latencyMs: number | null; // SSH transport ping on the existing session, not a second TCP dial
responseLatencyMs?: number | null; // Existing stats exec response, including remote channel setup
netInterfaces: Array<{ // Per-interface network stats
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes
+1 -15
View File
@@ -1,11 +1,6 @@
import type { S3Config, SyncedFile, WebDAVConfig } from "../../domain/sync";
import type { AppLockSettings } from "../../domain/appLock";
export interface HostBrowserPanelSnapshotLayer {
image: string;
bounds: { x: number; y: number; width: number; height: number };
}
type AppLockRuntimeReason = 'startup' | 'idle' | 'manual' | 'background' | null;
interface AppLockRuntimeState {
@@ -107,17 +102,8 @@ declare global {
// Settings window
openSettingsWindow?(): Promise<boolean>;
setHostBrowserPanelLayout?(options: { browserId: string; visible: boolean; occluded?: boolean; theme?: 'dark' | 'light'; themeTokens?: Record<string, string>; bounds?: { x: number; y: number; width: number; height: number } }): Promise<boolean | { layers: HostBrowserPanelSnapshotLayer[] }>;
setHostBrowserPanelLayout?(options: { browserId: string; visible: boolean; theme?: 'dark' | 'light'; themeTokens?: Record<string, string>; bounds?: { x: number; y: number; width: number; height: number } }): Promise<boolean>;
closeHostBrowserPanel?(browserId: string): Promise<boolean>;
getHostBrowserPanelState?(browserId: string): Promise<import('../../domain/hostBrowser').HostBrowserState>;
commandHostBrowserPanel?(browserId: string, command: import('../../domain/hostBrowser').HostBrowserCommand): Promise<import('../../domain/hostBrowser').HostBrowserState>;
listHostBrowserPanelBookmarks?(browserId: string): Promise<import('../../domain/hostBrowser').HostBrowserBookmark[]>;
saveHostBrowserPanelBookmark?(browserId: string, bookmark: Partial<import('../../domain/browserBookmark').BrowserBookmark>): Promise<import('../../domain/browserBookmark').BrowserBookmark>;
removeHostBrowserPanelBookmark?(browserId: string, id: string): Promise<boolean>;
listHostBrowserPanelDownloads?(browserId: string): Promise<import('../../domain/hostBrowser').HostBrowserDownload[]>;
commandHostBrowserPanelDownload?(browserId: string, command: { type: 'open-folder' | 'clear' | 'cancel' | 'remove' | 'open' | 'reveal' | 'save-as'; id?: string }): Promise<boolean>;
onHostBrowserPanelEvent?(callback: (event: import('../../domain/hostBrowser').HostBrowserPanelEvent) => void): () => void;
openHostBrowserUrl?(options: { browserId: string; url: string }): Promise<boolean>;
requestHostBrowserPanel?(request: { host: import('../../domain/models').Host; sessionId?: string; url?: string }): Promise<boolean>;
onHostBrowserPanelRequest?(callback: (request: { host: import('../../domain/models').Host; sessionId?: string; url?: string }) => void): () => void;