diff --git a/frontend/src/api/modules/ai.ts b/frontend/src/api/modules/ai.ts index c0f2e0b15e48..68a0ba51b455 100644 --- a/frontend/src/api/modules/ai.ts +++ b/frontend/src/api/modules/ai.ts @@ -41,11 +41,20 @@ export const updateBindDomain = (req: AI.BindDomain) => { export const loadGPUInfo = () => { return http.get(`/ai/gpu/load`); }; -export const getGPUOptions = () => { - return http.get(`/ai/gpu/options`); +export const getGPUOptions = (currentNode?: string) => { + return http.get( + `/ai/gpu/options`, + {}, + currentNode ? { headers: { CurrentNode: currentNode } } : {}, + ); }; -export const loadGPUMonitor = (param: AI.MonitorGPUSearch) => { - return http.post(`/ai/gpu/search`, param); +export const loadGPUMonitor = (param: AI.MonitorGPUSearch, currentNode?: string) => { + return http.post( + `/ai/gpu/search`, + param, + TimeoutEnum.T_60S, + currentNode ? { CurrentNode: currentNode } : undefined, + ); }; export const pageMcpServer = (req: AI.McpServerSearch) => { diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index fc99b678d92b..48a61d0f6ddd 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -2074,6 +2074,22 @@ const message = { profileGoroutineDesc: 'Saves the state and call stack of every goroutine and groups identical call paths.', profileMutexDesc: 'Measures wait time caused by mutex contention to locate lock contention hotspots.', profileBlockDesc: 'Measures blocking on channels, select statements, and synchronization primitives.', + exportRange: 'Time range', + exportMetrics: 'Metrics', + exportMetricsHelper: 'CPU, memory and load metrics are merged into one table.', + exportAllDevices: 'Each device (per-device detail)', + exportAllInterfaces: 'Each interface (per-interface detail)', + exportFormat: 'Format', + exportCsvHelper: 'Multiple metrics are exported as separate CSV files packed into a ZIP archive.', + exportJsonHelper: 'Export as a single structured JSON file.', + exportXlsxHelper: 'Each metric is exported as a separate worksheet in one workbook.', + exportTruncated: 'Excel row limit reached; these datasets were truncated: {0}', + exportEmptyTables: 'No data in the selected time range for: {0}', + exportAggregate: 'Combined total (all devices)', + exportAllGPUs: 'Each model (per-model detail)', + exportOptionsFailed: 'Failed to load the device list for: {0}. Refresh the page and retry.', + selectMetrics: 'Select at least one metric to export.', + selectTimeRange: 'Select the time range to export.', }, terminal: { showTerminalButton: 'Terminal Shortcut', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 1cd9df4b1c07..f60bbaf48844 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -2113,6 +2113,22 @@ const message = { profileMutexDesc: 'Mide el tiempo de espera causado por la contención de mutex para localizar bloqueos disputados.', profileBlockDesc: 'Mide los bloqueos en canales, select y primitivas de sincronización.', + exportRange: 'Intervalo de tiempo', + exportMetrics: 'Métricas', + exportMetricsHelper: 'Las métricas de CPU, memoria y carga se combinan en una sola tabla.', + exportAllDevices: 'Cada dispositivo (detalle por dispositivo)', + exportAllInterfaces: 'Cada interfaz (detalle por interfaz)', + exportFormat: 'Formato', + exportCsvHelper: 'Al seleccionar varias métricas se generan CSV separados empaquetados en un ZIP.', + exportJsonHelper: 'Exportar como un único archivo JSON estructurado.', + exportXlsxHelper: 'Cada métrica se exporta en una hoja independiente.', + exportTruncated: 'Se superó el límite de filas de Excel; estos conjuntos se truncaron: {0}', + exportEmptyTables: 'Sin datos en el rango seleccionado para: {0}', + exportAggregate: 'Total combinado (todos los dispositivos)', + exportAllGPUs: 'Cada modelo (detalle por modelo)', + exportOptionsFailed: 'No se pudo cargar la lista de dispositivos para: {0}. Actualiza la página y reintenta.', + selectMetrics: 'Selecciona al menos una métrica para exportar', + selectTimeRange: 'Selecciona el intervalo de tiempo a exportar', }, terminal: { showTerminalButton: 'Acceso rápido al terminal', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index e5dee8b8a2d9..c09da1216100 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -2055,6 +2055,23 @@ const message = { profileGoroutineDesc: 'وضعیت و پشته فراخوانی هر Goroutine را ذخیره و مسیرهای یکسان را گروه‌بندی می‌کند.', profileMutexDesc: 'زمان انتظار ناشی از رقابت mutex را برای یافتن نقاط رقابت قفل اندازه‌گیری می‌کند.', profileBlockDesc: 'زمان انسداد روی Channel، Select و ابزارهای همگام‌سازی را اندازه‌گیری می‌کند.', + exportRange: 'بازه زمانی', + exportMetrics: 'معیارها', + exportMetricsHelper: 'معیارهای CPU، حافظه و بار در یک جدول ادغام می‌شوند.', + exportAllDevices: 'هر دستگاه (جزئیات به تفکیک دستگاه)', + exportAllInterfaces: 'هر رابط (جزئیات به تفکیک رابط)', + exportFormat: 'قالب', + exportCsvHelper: 'هنگام انتخاب چند معیار، فایل‌های CSV جداگانه در یک بایگانی ZIP بسته‌بندی می‌شوند.', + exportJsonHelper: 'به‌صورت یک فایل JSON ساخت‌یافته واحد خروجی بگیرید.', + exportXlsxHelper: 'هر معیار به‌صورت یک کاربرگ جداگانه خروجی گرفته می‌شود.', + exportTruncated: 'از حد ردیف اکسل فراتر رفت؛ این مجموعه‌داده‌ها بریده شدند: {0}', + exportEmptyTables: 'در بازه انتخاب‌شده داده‌ای برای این موارد نیست: {0}', + exportAggregate: 'مجموع ترکیبی (همه دستگاه‌ها)', + exportAllGPUs: 'هر مدل (جزئیات به تفکیک مدل)', + exportOptionsFailed: + 'بارگیری فهرست دستگاه برای موارد زیر ناموفق بود: {0}. صفحه را تازه کرده و دوباره تلاش کنید.', + selectMetrics: 'حداقل یک معیار برای خروجی انتخاب کنید', + selectTimeRange: 'بازه زمانی خروجی را انتخاب کنید', }, terminal: { showTerminalButton: 'میانبر ترمینال', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index f1f9410e3e54..1274239be78a 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -2063,6 +2063,22 @@ const message = { profileGoroutineDesc: 'すべての Goroutine の状態と呼び出しスタックを保存し、同じ呼び出し経路を集約します。', profileMutexDesc: 'Mutex 競合による待機時間を測定し、ロック競合のホットスポットを特定します。', profileBlockDesc: 'Channel、Select、同期プリミティブでのブロック待機時間を測定します。', + exportRange: '期間', + exportMetrics: 'エクスポート指標', + exportMetricsHelper: 'CPU、メモリ、負荷の指標は 1 つのテーブルにまとめられます。', + exportAllDevices: 'デバイスごと(デバイス別明細)', + exportAllInterfaces: 'インターフェースごと(インターフェース別明細)', + exportFormat: 'エクスポート形式', + exportCsvHelper: '複数の指標を選択した場合、指標ごとに CSV ファイルを生成して ZIP にまとめます。', + exportJsonHelper: '構造化された単一の JSON ファイルとしてエクスポートします。', + exportXlsxHelper: '指標ごとに独立したワークシートを生成します。', + exportTruncated: 'Excel の行数上限を超えたため、以下のデータセットは切り詰められました: {0}', + exportEmptyTables: '選択した期間にデータがない指標: {0}', + exportAggregate: '合計(全デバイス合算)', + exportAllGPUs: 'モデルごと(モデル別明細)', + exportOptionsFailed: '次の指標のデバイス一覧を読み込めませんでした。再読み込みして再試行してください: {0}', + selectMetrics: 'エクスポートする指標を 1 つ以上選択してください', + selectTimeRange: 'エクスポートする期間を選択してください', }, terminal: { showTerminalButton: 'ターミナルへのショートカット', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 9263305dadef..4ec8e178087b 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -2037,6 +2037,22 @@ const message = { profileGoroutineDesc: '모든 Goroutine의 상태와 호출 스택을 저장하고 동일한 호출 경로를 그룹화합니다.', profileMutexDesc: 'Mutex 경합으로 인한 대기 시간을 측정하여 잠금 경합 지점을 찾습니다.', profileBlockDesc: 'Channel, Select 및 동기화 프리미티브의 차단 대기 시간을 측정합니다.', + exportRange: '기간', + exportMetrics: '보낼 지표', + exportMetricsHelper: 'CPU, 메모리, 로드 지표는 하나의 테이블로 병합됩니다.', + exportAllDevices: '장치별 (장치별 상세)', + exportAllInterfaces: '인터페이스별 (인터페이스별 상세)', + exportFormat: '보내기 형식', + exportCsvHelper: '여러 지표를 선택하면 지표별 CSV 파일을 생성해 ZIP으로 묶습니다.', + exportJsonHelper: '하나의 구조화된 JSON 파일로보냅니다.', + exportXlsxHelper: '지표별로 개별 워크시트를 생성합니다.', + exportTruncated: 'Excel 행 제한을 초과하여 다음 데이터셋이 잘렸습니다: {0}', + exportEmptyTables: '선택한 기간에 데이터가 없는 지표: {0}', + exportAggregate: '합계 (모든 장치 합산)', + exportAllGPUs: '모델별 (모델별 상세)', + exportOptionsFailed: '다음 지표의 장치 목록을 불러오지 못했습니다. 새로고침 후 다시 시도하세요: {0}', + selectMetrics: '보낼 지표를 하나 이상 선택하세요', + selectTimeRange: '보낼 기간을 선택하세요', }, terminal: { showTerminalButton: '터미널 바로가기', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index e93a93874a69..a16f2a055d03 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -2021,6 +2021,22 @@ const message = { profileGoroutineDesc: 'ບັນທຶກສະຖານະ ແລະ Call Stack ຂອງທຸກ Goroutine ແລະ ຈັດກຸ່ມເສັ້ນທາງການເອີ້ນທີ່ຄືກັນ.', profileMutexDesc: 'ວັດແທກເວລາລໍຖ້າທີ່ເກີດຈາກ Mutex Contention ເພື່ອຊອກຫາຈຸດທີ່ມີການແຂ່ງຂັນ Lock ສູງ.', profileBlockDesc: 'ວັດແທກການບລັອກໃນ Channel, Select Statement ແລະ ກົນໄກ Synchronization.', + exportRange: 'ຊ່ວງເວລາ', + exportMetrics: 'ຕົວຊີ້ວັດ', + exportMetricsHelper: 'ຕົວຊີ້ວັດ CPU, ຫນ່ວຍຄວາມຈຳ ແລະ ໂຫຼດ ຈະຖືກຮວມເຂົ້າເປັນຕາຕະລາງດຽວ.', + exportAllDevices: 'ແຕ່ລະອຸປະກອນ (ລາຍລະອຽດຕໍ່ອຸປະກອນ)', + exportAllInterfaces: 'ແຕ່ລະອິນເຕີເຟສ (ລາຍລະອຽດຕໍ່ອິນເຕີເຟສ)', + exportFormat: 'ຮູບແບບ', + exportCsvHelper: 'ເມື່ອເລືອກຫຼາຍຕົວຊີ້ວັດ ຈະສ້າງໄຟລ໌ CSV ແຍກກັນແລ້ວຮັດຮວມເປັນ ZIP.', + exportJsonHelper: 'ສົ່ງອອກເປັນໄຟລ໌ JSON ໂຄງສ້າງດຽວ.', + exportXlsxHelper: 'ແຕ່ລະຕົວຊີ້ວັດຈະຖືກສົ່ງອອກເປັນເວີກຊີດແຍກກັນ.', + exportTruncated: 'ເກີນຂີດຈຳກັດແຖວຂອງ Excel, ຊຸດຂໍ້ມູນຕໍ່ໄປນີ້ຖືກຕັດ: {0}', + exportEmptyTables: 'ບໍ່ມີຂໍ້ມູນໃນຊ່ວງເວລາທີ່ເລືອກສຳລັບ: {0}', + exportAggregate: 'ລວມທັງໝົດ (ທຸກອຸປະກອນ)', + exportAllGPUs: 'ແຕ່ລະໂມເດລ (ລາຍລະອຽດຕໍ່ໂມເດລ)', + exportOptionsFailed: 'ໂຫຼດລາຍຊື່ອຸປະກອນສຳລັບລາຍການຕໍ່ໄປນີ້ບໍ່ສຳເລັດ: {0}. ໂຫຼດໜ້າໃໝ່ແລ້ວລອງອີກຄັ້ງ.', + selectMetrics: 'ກະລຸນາເລືອກຢ່າງໜ້ອຍໜຶ່ງຕົວຊີ້ວັດທີ່ຈະສົ່ງອອກ', + selectTimeRange: 'ກະລຸນາເລືອກຊ່ວງເວລາທີ່ຈະສົ່ງອອກ', }, terminal: { showTerminalButton: 'ທາງລັດ Terminal', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 22f4e1edc325..49dee8ddac70 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -2101,6 +2101,22 @@ const message = { 'Menyimpan keadaan dan tindanan panggilan setiap Goroutine serta mengumpulkan laluan yang sama.', profileMutexDesc: 'Mengukur masa menunggu akibat persaingan mutex untuk mencari titik persaingan kunci.', profileBlockDesc: 'Mengukur sekatan pada Channel, Select dan primitif penyegerakan.', + exportRange: 'Julat masa', + exportMetrics: 'Metrik', + exportMetricsHelper: 'Metrik CPU, memori dan beban digabungkan dalam satu jadual.', + exportAllDevices: 'Setiap peranti (perincian per peranti)', + exportAllInterfaces: 'Setiap antara muka (perincian per antara muka)', + exportFormat: 'Format', + exportCsvHelper: 'Apabila berbilang metrik dipilih, fail CSV berasingan dibungkus dalam ZIP.', + exportJsonHelper: 'Eksport sebagai satu fail JSON berstruktur.', + exportXlsxHelper: 'Setiap metrik dieksport sebagai lembaran kerja berasingan.', + exportTruncated: 'Had baris Excel dicapai; set data berikut telah dipotong: {0}', + exportEmptyTables: 'Tiada data dalam julat dipilih untuk: {0}', + exportAggregate: 'Jumlah gabungan (semua peranti)', + exportAllGPUs: 'Setiap model (perincian per model)', + exportOptionsFailed: 'Gagal memuat senarai peranti untuk: {0}. Muat semula halaman dan cuba lagi.', + selectMetrics: 'Pilih sekurang-kurangnya satu metrik untuk dieksport', + selectTimeRange: 'Pilih julat masa untuk dieksport', }, terminal: { showTerminalButton: 'Pintasan Terminal', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 3a5aaeae138e..b81beeada179 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -2106,6 +2106,23 @@ const message = { profileGoroutineDesc: 'Salva o estado e a pilha de cada goroutine e agrupa caminhos de chamada idênticos.', profileMutexDesc: 'Mede o tempo de espera causado por contenção de mutex para localizar pontos de disputa.', profileBlockDesc: 'Mede bloqueios em canais, select e primitivas de sincronização.', + exportRange: 'Período', + exportMetrics: 'Métricas', + exportMetricsHelper: 'As métricas de CPU, memória e carga são mescladas em uma única tabela.', + exportAllDevices: 'Cada dispositivo (detalhe por dispositivo)', + exportAllInterfaces: 'Cada interface (detalhe por interface)', + exportFormat: 'Formato', + exportCsvHelper: 'Ao selecionar várias métricas, são gerados arquivos CSV separados compactados em ZIP.', + exportJsonHelper: 'Exportar como um único arquivo JSON estruturado.', + exportXlsxHelper: 'Cada métrica é exportada em uma planilha separada.', + exportTruncated: 'Limite de linhas do Excel atingido; estes conjuntos foram truncados: {0}', + exportEmptyTables: 'Sem dados no período selecionado para: {0}', + exportAggregate: 'Total combinado (todos os dispositivos)', + exportAllGPUs: 'Cada modelo (detalhe por modelo)', + exportOptionsFailed: + 'Falha ao carregar a lista de dispositivos para: {0}. Atualize a página e tente novamente.', + selectMetrics: 'Selecione pelo menos uma métrica para exportar', + selectTimeRange: 'Selecione o período para exportar', }, terminal: { showTerminalButton: 'Atalho para o terminal', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index beafc89ae40d..ea34011616b8 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -2087,6 +2087,22 @@ const message = { profileGoroutineDesc: 'Сохраняет состояние и стек каждой goroutine и группирует одинаковые пути вызовов.', profileMutexDesc: 'Измеряет ожидание из-за конкуренции mutex для поиска горячих блокировок.', profileBlockDesc: 'Измеряет блокировки на каналах, select и примитивах синхронизации.', + exportRange: 'Диапазон времени', + exportMetrics: 'Метрики', + exportMetricsHelper: 'Метрики CPU, памяти и нагрузки объединяются в одну таблицу.', + exportAllDevices: 'Каждое устройство (по отдельности)', + exportAllInterfaces: 'Каждый интерфейс (по отдельности)', + exportFormat: 'Формат', + exportCsvHelper: 'При выборе нескольких метрик CSV-файлы упаковываются в ZIP-архив.', + exportJsonHelper: 'Экспорт в один структурированный JSON-файл.', + exportXlsxHelper: 'Каждая метрика экспортируется на отдельный лист.', + exportTruncated: 'Превышен лимит строк Excel, эти наборы данных обрезаны: {0}', + exportEmptyTables: 'Нет данных за выбранный период: {0}', + exportAggregate: 'Сводный итог (все устройства)', + exportAllGPUs: 'Каждая модель (по отдельности)', + exportOptionsFailed: 'Не удалось загрузить список устройств для: {0}. Обновите страницу и повторите.', + selectMetrics: 'Выберите хотя бы одну метрику для экспорта', + selectTimeRange: 'Выберите диапазон времени для экспорта', }, terminal: { showTerminalButton: 'Быстрый доступ к терминалу', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index c13384832a70..2b6360bb6148 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -2093,6 +2093,22 @@ const message = { profileGoroutineDesc: 'Her goroutine durumunu ve çağrı yığınını kaydeder, aynı çağrı yollarını gruplar.', profileMutexDesc: 'Kilit çekişmesi noktalarını bulmak için mutex bekleme süresini ölçer.', profileBlockDesc: 'Channel, select ve eşzamanlama araçlarındaki engelleme süresini ölçer.', + exportRange: 'Zaman aralığı', + exportMetrics: 'Metrikler', + exportMetricsHelper: 'CPU, bellek ve yük metrikleri tek tabloda birleştirilir.', + exportAllDevices: 'Her aygıt (aygıt başına ayrıntı)', + exportAllInterfaces: 'Her arabirim (arabirim başına ayrıntı)', + exportFormat: 'Biçim', + exportCsvHelper: 'Birden fazla metrik seçildiğinde ayrı CSV dosyaları ZIP arşivinde paketlenir.', + exportJsonHelper: 'Tek bir yapılandırılmış JSON dosyası olarak dışa aktar.', + exportXlsxHelper: 'Her metrik ayrı bir çalışma sayfası olarak dışa aktarılır.', + exportTruncated: 'Excel satır sınırı aşıldı; şu veri kümeleri kırpıldı: {0}', + exportEmptyTables: 'Seçilen aralıkta veri yok: {0}', + exportAggregate: 'Birleşik toplam (tüm aygıtlar)', + exportAllGPUs: 'Her model (model başına ayrıntı)', + exportOptionsFailed: 'Şu metrikler için aygıt listesi yüklenemedi: {0}. Sayfayı yenileyip tekrar deneyin.', + selectMetrics: 'Dışa aktarmak için en az bir metrik seçin', + selectTimeRange: 'Dışa aktarılacak zaman aralığını seçin', }, terminal: { showTerminalButton: 'Terminal kısayolu', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 964ba6996b2a..c24f87faefbd 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1959,6 +1959,22 @@ const message = { profileGoroutineDesc: '儲存所有 Goroutine 的狀態和呼叫堆疊,並依相同呼叫鏈彙總。', profileMutexDesc: '統計互斥鎖競爭造成的等待時間,用於定位鎖競爭熱點。', profileBlockDesc: '統計 Channel、Select 和同步原語的阻塞等待,用於定位長時間阻塞。', + exportRange: '時間範圍', + exportMetrics: '匯出指標', + exportMetricsHelper: 'CPU、記憶體與負載指標將合併到同一張表。', + exportAllDevices: '每個設備(逐設備明細)', + exportAllInterfaces: '每個介面(逐介面明細)', + exportFormat: '匯出格式', + exportCsvHelper: '選擇多個指標時將分別產生 CSV 檔案並打包為 ZIP。', + exportJsonHelper: '匯出為單一結構化 JSON 檔案。', + exportXlsxHelper: '每個指標產生一個獨立工作表。', + exportTruncated: '已超出 Excel 單表行數上限,以下數據集已截斷:{0}', + exportEmptyTables: '以下指標在所選時間段內沒有數據:{0}', + exportAggregate: '合計(所有裝置求和)', + exportAllGPUs: '每個型號(逐型號明細)', + exportOptionsFailed: '無法載入以下指標的裝置清單,請重新整理後重試:{0}', + selectMetrics: '請至少選擇一個匯出指標', + selectTimeRange: '請選擇匯出時間範圍', }, terminal: { showTerminalButton: '終端捷徑', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index b25d03d0c4cc..adc996fc1ceb 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -1989,6 +1989,22 @@ const message = { profileGoroutineDesc: '保存所有 Goroutine 的状态和调用栈,并按相同调用链聚合。', profileMutexDesc: '统计互斥锁竞争造成的等待时间,用于定位锁竞争热点。', profileBlockDesc: '统计 Channel、Select 和同步原语的阻塞等待,用于定位长时间阻塞。', + exportRange: '时间范围', + exportMetrics: '导出指标', + exportMetricsHelper: 'CPU、内存与负载指标将合并到同一张表。', + exportAllDevices: '每个设备(逐设备明细)', + exportAllInterfaces: '每个接口(逐接口明细)', + exportFormat: '导出格式', + exportCsvHelper: '选择多个指标时将分别生成 CSV 文件并打包为 ZIP。', + exportJsonHelper: '导出为单个结构化 JSON 文件。', + exportXlsxHelper: '每个指标生成一个独立工作表。', + exportTruncated: '已超出 Excel 单表行数上限,以下数据集已截断:{0}', + exportEmptyTables: '以下指标在所选时间段内没有数据:{0}', + exportAggregate: '合计(所有设备求和)', + exportAllGPUs: '每个型号(逐型号明细)', + exportOptionsFailed: '无法加载以下指标的设备列表,请刷新页面后重试:{0}', + selectMetrics: '请至少选择一个导出指标', + selectTimeRange: '请选择导出时间范围', }, terminal: { showTerminalButton: '终端快捷入口', diff --git a/frontend/src/routers/modules/host.ts b/frontend/src/routers/modules/host.ts index 2fd802d2ed03..765019f7d45d 100644 --- a/frontend/src/routers/modules/host.ts +++ b/frontend/src/routers/modules/host.ts @@ -32,6 +32,18 @@ const hostRouter = { permission: 'host_monitor_view', }, }, + { + path: '/hosts/monitor/export', + name: 'HostMonitorExport', + component: () => import('@/views/host/monitor/export/index.vue'), + hidden: true, + meta: { + activeMenu: '/hosts/monitor/monitor', + title: 'menu.monitor', + detail: 'commons.button.export', + permission: 'host_monitor_view', + }, + }, { path: '/hosts/monitor/setting', name: 'HostMonitorSetting', diff --git a/frontend/src/utils/file.ts b/frontend/src/utils/file.ts index a3f7abee65b0..e022f3322598 100644 --- a/frontend/src/utils/file.ts +++ b/frontend/src/utils/file.ts @@ -145,14 +145,20 @@ export function downloadFile(filePath: string, currentNode: string) { window.open(buildFileDownloadUrl(filePath, currentNode), '_blank'); } -export function downloadWithContent(content: string, fileName: string) { - const downloadUrl = window.URL.createObjectURL(new Blob([content])); +export function downloadBlob(content: string | Uint8Array, fileName: string, type?: string) { + const downloadUrl = window.URL.createObjectURL(new Blob([content], type ? { type } : undefined)); const a = document.createElement('a'); a.style.display = 'none'; a.href = downloadUrl; a.download = fileName; const event = new MouseEvent('click'); a.dispatchEvent(event); + // revoke asynchronously: revoking right after the click can cancel the download in some browsers + setTimeout(() => window.URL.revokeObjectURL(downloadUrl), 1000); +} + +export function downloadWithContent(content: string, fileName: string) { + downloadBlob(content, fileName); } const editorLanguages = new Map( diff --git a/frontend/src/utils/monitor-export.ts b/frontend/src/utils/monitor-export.ts new file mode 100644 index 000000000000..7f0e01b26b65 --- /dev/null +++ b/frontend/src/utils/monitor-export.ts @@ -0,0 +1,338 @@ +import { dateFormatRFC3339 } from '@/utils/date'; +import { downloadBlob } from '@/utils/file'; + +export interface MonitorExportTable { + name: string; + fields: string[]; + headers: string[]; + rows: any[][]; +} + +const formatCell = (cell: any): string => { + if (cell === null || cell === undefined) { + return ''; + } + if (cell instanceof Date) { + // RFC3339 keeps the UTC offset so exported timestamps stay unambiguous across DST transitions + return dateFormatRFC3339(cell); + } + return String(cell); +}; + +const isNumberCell = (cell: any): boolean => typeof cell === 'number' && Number.isFinite(cell); + +export function buildMonitorCSV(table: MonitorExportTable): string { + const escape = (value: string) => { + // neutralize formula execution when a non-numeric cell starts with = + - @ or a control char + let sanitized = value; + if (/^[=+\-@\t]/.test(sanitized) && Number.isNaN(Number(sanitized))) { + sanitized = `'${sanitized}`; + } + return /[",\n\r]/.test(sanitized) ? `"${sanitized.replace(/"/g, '""')}"` : sanitized; + }; + const lines = [table.headers.map(escape).join(',')]; + for (const row of table.rows) { + lines.push(row.map((cell) => escape(formatCell(cell))).join(',')); + } + return String.fromCharCode(0xfeff) + lines.join('\r\n'); +} + +export function buildMonitorJSON(tables: MonitorExportTable[]): string { + const data: Record = {}; + for (const table of tables) { + data[table.name] = table.rows.map((row) => { + const item: Record = {}; + table.fields.forEach((field, i) => { + item[field] = row[i] instanceof Date ? formatCell(row[i]) : (row[i] ?? null); + }); + return item; + }); + } + return JSON.stringify(data, null, 2); +} + +// minimal zip writer for csv bundles and xlsx containers: deflate when the runtime offers it, store otherwise +const crcTable = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c; + } + return table; +})(); + +const crc32 = (data: Uint8Array): number => { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) { + crc = crcTable[(crc ^ data[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +}; + +const dosDateTime = (date: Date) => { + const time = + ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() >> 1) & 0x1f); + const day = + (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f); + return { time, day }; +}; + +export interface ZipEntry { + name: string; + content: string | Uint8Array; +} + +// deflate-raw through the browser's native CompressionStream; null means unsupported, caller stores instead +const deflateRaw = async (data: Uint8Array): Promise | null> => { + if (typeof CompressionStream === 'undefined') { + return null; + } + try { + const stream = new Blob([data]).stream().pipeThrough(new CompressionStream('deflate-raw')); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } catch { + // engines that predate the 'deflate-raw' format throw on construction + return null; + } +}; + +export async function zipFiles(entries: ZipEntry[]): Promise> { + const encoder = new TextEncoder(); + const chunks: Uint8Array[] = []; + const central: Uint8Array[] = []; + let offset = 0; + const { time, day } = dosDateTime(new Date()); + + const prepared = await Promise.all( + entries.map(async (entry) => { + const raw = typeof entry.content === 'string' ? encoder.encode(entry.content) : entry.content; + const deflated = await deflateRaw(raw); + const useDeflate = deflated !== null && deflated.length < raw.length; + return { + nameBytes: encoder.encode(entry.name), + crc: crc32(raw), + size: raw.length, + method: useDeflate ? 8 : 0, + data: useDeflate ? deflated : raw, + }; + }), + ); + + for (const item of prepared) { + const local = new DataView(new ArrayBuffer(30)); + local.setUint32(0, 0x04034b50, true); + local.setUint16(4, 20, true); + local.setUint16(6, 0x0800, true); + local.setUint16(8, item.method, true); + local.setUint16(10, time, true); + local.setUint16(12, day, true); + local.setUint32(14, item.crc, true); + local.setUint32(18, item.data.length, true); + local.setUint32(22, item.size, true); + local.setUint16(26, item.nameBytes.length, true); + chunks.push(new Uint8Array(local.buffer), item.nameBytes, item.data); + + const header = new DataView(new ArrayBuffer(46)); + header.setUint32(0, 0x02014b50, true); + header.setUint16(4, 20, true); + header.setUint16(6, 20, true); + header.setUint16(8, 0x0800, true); + header.setUint16(10, item.method, true); + header.setUint16(12, time, true); + header.setUint16(14, day, true); + header.setUint32(16, item.crc, true); + header.setUint32(20, item.data.length, true); + header.setUint32(24, item.size, true); + header.setUint16(28, item.nameBytes.length, true); + header.setUint32(42, offset, true); + central.push(new Uint8Array(header.buffer), item.nameBytes); + + offset += 30 + item.nameBytes.length + item.data.length; + } + + const centralOffset = offset; + let centralSize = 0; + for (const chunk of central) { + centralSize += chunk.length; + } + const end = new DataView(new ArrayBuffer(22)); + end.setUint32(0, 0x06054b50, true); + end.setUint16(8, entries.length, true); + end.setUint16(10, entries.length, true); + end.setUint32(12, centralSize, true); + end.setUint32(16, centralOffset, true); + chunks.push(...central, new Uint8Array(end.buffer)); + + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const result = new Uint8Array(total); + let pos = 0; + for (const chunk of chunks) { + result.set(chunk, pos); + pos += chunk.length; + } + return result; +} + +const xmlEscape = (value: string) => + value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +const xlsxColumnName = (index: number): string => { + let name = ''; + for (let n = index; ;) { + name = String.fromCharCode(65 + (n % 26)) + name; + n = Math.floor(n / 26) - 1; + if (n < 0) { + return name; + } + } +}; + +// Excel serial keeps the local wall-clock time so the displayed value matches the panel UI +const xlsxDateSerial = (date: Date) => (date.getTime() - date.getTimezoneOffset() * 60000) / 86400000 + 25569; + +const xlsxSheet = (table: MonitorExportTable): string => { + const parts = [ + ``, + ``, + ]; + const writeRow = (index: number, cells: any[], isHeader = false) => { + parts.push(``); + cells.forEach((cell, i) => { + const ref = `${xlsxColumnName(i)}${index}`; + if (!isHeader && cell instanceof Date) { + parts.push(`${xlsxDateSerial(cell)}`); + } else if (!isHeader && isNumberCell(cell)) { + parts.push(`${cell}`); + } else { + parts.push(`${xmlEscape(formatCell(cell))}`); + } + }); + parts.push(``); + }; + writeRow(1, table.headers, true); + table.rows.forEach((row, i) => writeRow(i + 2, row)); + parts.push(``); + return parts.join(''); +}; + +export function buildMonitorXLSX(tables: MonitorExportTable[]): Promise> { + const sheets = tables.map( + (table, i) => ``, + ); + const overrides = tables + .map( + (_table, i) => + ``, + ) + .join(''); + const sheetRels = tables + .map( + (_table, i) => + ``, + ) + .join(''); + + const entries: ZipEntry[] = [ + { + name: '[Content_Types].xml', + content: + `` + + `` + + `` + + `` + + `` + + `` + + overrides + + ``, + }, + { + name: '_rels/.rels', + content: + `` + + `` + + `` + + ``, + }, + { + name: 'xl/workbook.xml', + content: + `` + + `` + + `${sheets.join('')}`, + }, + { + name: 'xl/_rels/workbook.xml.rels', + content: + `` + + `${sheetRels}` + + `` + + ``, + }, + { + name: 'xl/styles.xml', + content: + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``, + }, + ]; + tables.forEach((table, i) => { + entries.push({ name: `xl/worksheets/sheet${i + 1}.xml`, content: xlsxSheet(table) }); + }); + return zipFiles(entries); +} + +// Excel worksheets are limited to 1,048,576 rows (header included) +export const XLSX_MAX_DATA_ROWS = 1048575; + +// returns the names of tables whose rows were truncated to fit format limits +export async function exportMonitorTables( + tables: MonitorExportTable[], + format: string, + baseName: string, +): Promise { + switch (format) { + case 'json': + downloadBlob(buildMonitorJSON(tables), `${baseName}.json`, 'application/json'); + return []; + case 'xlsx': { + const truncated: string[] = []; + const limited = tables.map((table) => { + if (table.rows.length <= XLSX_MAX_DATA_ROWS) { + return table; + } + truncated.push(table.name); + return { ...table, rows: table.rows.slice(0, XLSX_MAX_DATA_ROWS) }; + }); + downloadBlob( + await buildMonitorXLSX(limited), + `${baseName}.xlsx`, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + return truncated; + } + default: + if (tables.length === 1) { + downloadBlob(buildMonitorCSV(tables[0]), `${baseName}-${tables[0].name}.csv`, 'text/csv'); + } else { + const bundle = await zipFiles( + tables.map((table) => ({ name: `monitor-${table.name}.csv`, content: buildMonitorCSV(table) })), + ); + downloadBlob(bundle, `${baseName}.zip`, 'application/zip'); + } + return []; + } +} diff --git a/frontend/src/views/host/monitor/export/index.vue b/frontend/src/views/host/monitor/export/index.vue new file mode 100644 index 000000000000..a3fa4e77d521 --- /dev/null +++ b/frontend/src/views/host/monitor/export/index.vue @@ -0,0 +1,424 @@ + + + diff --git a/frontend/src/views/host/monitor/index.vue b/frontend/src/views/host/monitor/index.vue index b4cacb98b3f7..72a64871c742 100644 --- a/frontend/src/views/host/monitor/index.vue +++ b/frontend/src/views/host/monitor/index.vue @@ -15,6 +15,10 @@ const buttons = [ label: i18n.global.t('menu.monitor'), path: '/hosts/monitor/monitor', }, + { + label: i18n.global.t('commons.button.export'), + path: '/hosts/monitor/export', + }, { label: i18n.global.t('file.setting'), path: '/hosts/monitor/setting',