feat(monitor): add monitor data export with csv, json and xlsx formats (#13875)

- new export tab between monitor and settings with time range, metric,
  device/interface/gpu selection and format choice
- files are generated in the browser from existing monitor apis; no new
  backend endpoints
- csv with utf-8 bom, escaping and formula sanitization; multiple metrics
  are zipped; xlsx uses one worksheet per metric with real date cells and
  row-limit warnings; json emits flat row objects
- share downloadBlob in utils/file with delayed url revocation
- use the 60s monitor timeout and node header for gpu queries

Co-authored-by: ssongliu <sloooop1x@gmail.com>
This commit is contained in:
CN_CoreSteb
2026-09-29 15:50:00 +08:00
committed by GitHub
co-authored by ssongliu
parent 2189d9229a
commit c1b2b92708
18 changed files with 1020 additions and 6 deletions
+13 -4
View File
@@ -41,11 +41,20 @@ export const updateBindDomain = (req: AI.BindDomain) => {
export const loadGPUInfo = () => {
return http.get<AI.Info>(`/ai/gpu/load`);
};
export const getGPUOptions = () => {
return http.get<AI.MonitorGPUOptions>(`/ai/gpu/options`);
export const getGPUOptions = (currentNode?: string) => {
return http.get<AI.MonitorGPUOptions>(
`/ai/gpu/options`,
{},
currentNode ? { headers: { CurrentNode: currentNode } } : {},
);
};
export const loadGPUMonitor = (param: AI.MonitorGPUSearch) => {
return http.post<AI.MonitorGPUData>(`/ai/gpu/search`, param);
export const loadGPUMonitor = (param: AI.MonitorGPUSearch, currentNode?: string) => {
return http.post<AI.MonitorGPUData>(
`/ai/gpu/search`,
param,
TimeoutEnum.T_60S,
currentNode ? { CurrentNode: currentNode } : undefined,
);
};
export const pageMcpServer = (req: AI.McpServerSearch) => {
+16
View File
@@ -2216,6 +2216,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',
+16
View File
@@ -2255,6 +2255,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',
+17
View File
@@ -2195,6 +2195,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: 'میانبر ترمینال',
+16
View File
@@ -2201,6 +2201,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: 'ターミナルへのショートカット',
+16
View File
@@ -2175,6 +2175,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: '터미널 바로가기',
+16
View File
@@ -2155,6 +2155,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',
+16
View File
@@ -2243,6 +2243,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',
+17
View File
@@ -2248,6 +2248,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',
+16
View File
@@ -2229,6 +2229,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: 'Быстрый доступ к терминалу',
+16
View File
@@ -2236,6 +2236,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',
+16
View File
@@ -2091,6 +2091,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: '終端捷徑',
+16
View File
@@ -2121,6 +2121,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: '终端快捷入口',
+12
View File
@@ -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',
+8 -2
View File
@@ -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<ArrayBuffer>, 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(
+338
View File
@@ -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<string, any[]> = {};
for (const table of tables) {
data[table.name] = table.rows.map((row) => {
const item: Record<string, any> = {};
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<ArrayBuffer>;
}
// deflate-raw through the browser's native CompressionStream; null means unsupported, caller stores instead
const deflateRaw = async (data: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer> | 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<Uint8Array<ArrayBuffer>> {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 = [
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>`,
];
const writeRow = (index: number, cells: any[], isHeader = false) => {
parts.push(`<row r="${index}">`);
cells.forEach((cell, i) => {
const ref = `${xlsxColumnName(i)}${index}`;
if (!isHeader && cell instanceof Date) {
parts.push(`<c r="${ref}" s="1"><v>${xlsxDateSerial(cell)}</v></c>`);
} else if (!isHeader && isNumberCell(cell)) {
parts.push(`<c r="${ref}"><v>${cell}</v></c>`);
} else {
parts.push(`<c r="${ref}" t="inlineStr"><is><t>${xmlEscape(formatCell(cell))}</t></is></c>`);
}
});
parts.push(`</row>`);
};
writeRow(1, table.headers, true);
table.rows.forEach((row, i) => writeRow(i + 2, row));
parts.push(`</sheetData></worksheet>`);
return parts.join('');
};
export function buildMonitorXLSX(tables: MonitorExportTable[]): Promise<Uint8Array<ArrayBuffer>> {
const sheets = tables.map(
(table, i) => `<sheet name="${xmlEscape(table.name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`,
);
const overrides = tables
.map(
(_table, i) =>
`<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`,
)
.join('');
const sheetRels = tables
.map(
(_table, i) =>
`<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`,
)
.join('');
const entries: ZipEntry[] = [
{
name: '[Content_Types].xml',
content:
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
`<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>` +
overrides +
`</Types>`,
},
{
name: '_rels/.rels',
content:
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>` +
`</Relationships>`,
},
{
name: 'xl/workbook.xml',
content:
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
`<sheets>${sheets.join('')}</sheets></workbook>`,
},
{
name: 'xl/_rels/workbook.xml.rels',
content:
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${sheetRels}` +
`<Relationship Id="rId${tables.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>` +
`</Relationships>`,
},
{
name: 'xl/styles.xml',
content:
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
`<numFmts count="1"><numFmt numFmtId="164" formatCode="yyyy-mm-dd hh:mm:ss"/></numFmts>` +
`<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>` +
`<fills count="2"><fill><patternFill/></fill><fill><patternFill patternType="gray125"/></fill></fills>` +
`<borders count="1"><border/></borders>` +
`<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>` +
`<cellXfs count="2">` +
`<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>` +
`<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>` +
`</cellXfs>` +
`<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>` +
`</styleSheet>`,
},
];
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<string[]> {
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 [];
}
}
@@ -0,0 +1,451 @@
<template>
<div>
<MonitorRouter />
<LayoutContent v-loading="loading" :title="$t('commons.button.export')" :divider="true">
<template #main>
<el-form @submit.prevent label-position="left" label-width="160px">
<el-row>
<el-col :span="1"><br /></el-col>
<el-col :xs="24" :sm="20" :md="16" :lg="12" :xl="10">
<el-form-item :label="$t('monitor.exportRange')">
<el-date-picker
v-model="timeRange"
type="datetimerange"
range-separator="-"
:start-placeholder="$t('commons.search.timeStart')"
:end-placeholder="$t('commons.search.timeEnd')"
:shortcuts="shortcuts"
:size="isMobile ? 'small' : 'default'"
class="w-full"
/>
</el-form-item>
<el-form-item :label="$t('monitor.exportMetrics')">
<div>
<el-checkbox-group v-model="form.params">
<el-checkbox label="CPU" value="cpu" />
<el-checkbox :label="$t('monitor.memory')" value="memory" />
<el-checkbox :label="$t('monitor.avgLoad')" value="load" />
<el-checkbox :label="$t('monitor.disk') + ' I/O'" value="io" />
<el-checkbox :label="$t('monitor.network')" value="network" />
<el-checkbox
v-if="gpuOptions.length > 0 || gpuOptionsFailed"
label="GPU"
value="gpu"
/>
</el-checkbox-group>
<span class="input-help">{{ $t('monitor.exportMetricsHelper') }}</span>
</div>
</el-form-item>
<el-form-item v-if="form.params.includes('io')" :label="$t('monitor.disk')">
<el-select v-model="form.io" class="w-full">
<el-option :label="$t('monitor.exportAllDevices')" value="*" />
<el-option
v-for="item in ioOptions"
:key="item"
:label="item === 'all' ? $t('monitor.exportAggregate') : item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item v-if="form.params.includes('network')" :label="$t('monitor.networkCard')">
<el-select v-model="form.network" class="w-full">
<el-option :label="$t('monitor.exportAllInterfaces')" value="*" />
<el-option
v-for="item in netOptions"
:key="item"
:label="item === 'all' ? $t('monitor.exportAggregate') : item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item v-if="form.params.includes('gpu')" label="GPU">
<el-select v-model="form.gpuDevice" class="w-full">
<el-option :label="$t('monitor.exportAllDevices')" value="*" />
<el-option
v-for="item in gpuOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t('monitor.exportFormat')">
<div>
<el-radio-group v-model="form.format">
<el-radio value="csv">CSV</el-radio>
<el-radio value="json">JSON</el-radio>
<el-radio value="xlsx">Excel (xlsx)</el-radio>
</el-radio-group>
<span class="input-help">{{ formatHelper }}</span>
</div>
</el-form-item>
<el-form-item>
<el-button
v-permission:view
v-node-admin
type="primary"
@click="onExport"
:disabled="loading"
>
{{ $t('commons.button.export') }}
</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
</template>
</LayoutContent>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { loadMonitor, getIOOptions, getNetworkOptions } from '@/api/modules/host';
import { getGPUOptions, loadGPUMonitor } from '@/api/modules/ai';
import { Host } from '@/api/interface/host';
import { AI } from '@/api/interface/ai';
import { shortcuts } from '@/utils/shortcuts';
import { dateFormatForName } from '@/utils/date';
import { exportMonitorTables, MonitorExportTable } from '@/utils/monitor-export';
import { useGlobalStore } from '@/composables/useGlobalStore';
import MonitorRouter from '@/views/host/monitor/index.vue';
import i18n from '@/lang';
import { MsgWarning } from '@/utils/message';
const { currentNode, isMobile } = useGlobalStore();
const loading = ref(false);
const timeRange = ref<[Date, Date]>([new Date(new Date().setHours(0, 0, 0, 0)), new Date()]);
const ioOptions = ref<Array<string>>([]);
const netOptions = ref<Array<string>>([]);
const gpuOptions = ref<(AI.ChartHide & { value: string; label: string })[]>([]);
const ioOptionsFailed = ref(false);
const netOptionsFailed = ref(false);
const gpuOptionsFailed = ref(false);
// el-select treats '' as empty and renders the placeholder, so "all devices" uses '*' as a sentinel
const ALL_DEVICES = '*';
const form = reactive({
params: ['cpu', 'memory', 'load'] as Array<string>,
io: ALL_DEVICES,
network: ALL_DEVICES,
gpuDevice: ALL_DEVICES,
format: 'csv',
});
const formatHelper = computed(() => {
switch (form.format) {
case 'json':
return i18n.global.t('monitor.exportJsonHelper');
case 'xlsx':
return i18n.global.t('monitor.exportXlsxHelper');
default:
return i18n.global.t('monitor.exportCsvHelper');
}
});
const t = (key: string, args?: any) => i18n.global.t(key, args);
// map internal table names to the localized metric labels shown in warnings
const tableLabel = (name: string): string => {
switch (name) {
case 'base':
return [
['cpu', 'CPU'],
['memory', t('monitor.memory')],
['load', t('monitor.avgLoad')],
]
.filter(([param]) => form.params.includes(param as string))
.map(([, label]) => label)
.join(' / ');
case 'io':
return `${t('monitor.disk')} I/O`;
case 'network':
return t('monitor.network');
case 'gpu':
return 'GPU';
default:
return name;
}
};
const buildBaseTable = (datasets: Array<Host.MonitorData>): MonitorExportTable => {
const base = datasets.find((item) => item.param === 'base');
const fields = ['time'];
const headers = [t('commons.table.date')];
const pickers: Array<(row: any) => any> = [(row) => new Date(row.createdAt)];
if (form.params.includes('cpu')) {
fields.push('cpu');
headers.push('CPU (%)');
pickers.push((row) => row.cpu);
}
if (form.params.includes('memory')) {
fields.push('memory');
headers.push(`${t('monitor.memory')} (%)`);
pickers.push((row) => row.memory);
}
if (form.params.includes('load')) {
fields.push('loadUsage', 'load1', 'load5', 'load15');
headers.push(`${t('monitor.avgLoad')} (%)`, 'Load 1m', 'Load 5m', 'Load 15m');
pickers.push(
(row) => row.loadUsage,
(row) => row.cpuLoad1,
(row) => row.cpuLoad5,
(row) => row.cpuLoad15,
);
}
const rows = (base?.value || []).map((row) => pickers.map((pick) => pick(row)));
return { name: 'base', fields, headers, rows };
};
const buildIOTable = (datasets: Array<Host.MonitorData>): MonitorExportTable => {
const rows = datasets
.flatMap((item) => (item.param === 'io' ? item.value || [] : []))
.filter((row) => form.io !== ALL_DEVICES || row.name !== 'all')
.map((row) => [new Date(row.createdAt), row.name, row.read, row.write, row.count, row.time])
.sort((a, b) => a[0].getTime() - b[0].getTime());
return {
name: 'io',
fields: ['time', 'device', 'read', 'write', 'count', 'ioTime'],
headers: [
t('commons.table.date'),
t('monitor.disk'),
`${t('monitor.read')} (B/s)`,
`${t('monitor.write')} (B/s)`,
t('monitor.readWriteCount'),
`${t('monitor.readWriteTime')} (ms)`,
],
rows,
};
};
const buildNetworkTable = (datasets: Array<Host.MonitorData>): MonitorExportTable => {
const rows = datasets
.flatMap((item) => (item.param === 'network' ? item.value || [] : []))
.filter((row) => form.network !== ALL_DEVICES || row.name !== 'all')
.map((row) => [new Date(row.createdAt), row.name, row.up, row.down])
.sort((a, b) => a[0].getTime() - b[0].getTime());
return {
name: 'network',
fields: ['time', 'interface', 'up', 'down'],
headers: [
t('commons.table.date'),
t('monitor.networkCard'),
`${t('monitor.up')} (KB/s)`,
`${t('monitor.down')} (KB/s)`,
],
rows,
};
};
const buildGPUTable = (datasets: Array<{ device: AI.ChartHide; data: AI.MonitorGPUData }>): MonitorExportTable => {
const rows = datasets
.flatMap(({ device, data }) =>
(data.date || []).map((date, i) => [
new Date(date),
device.productName,
device.deviceID || '',
!!device.legacy,
data.gpuValue?.[i],
data.temperatureValue?.[i],
data.powerUsed?.[i],
data.powerTotal?.[i],
data.memoryUsed?.[i],
data.memoryTotal?.[i],
data.speedValue?.[i],
]),
)
.sort((a, b) => (a[0] as Date).getTime() - (b[0] as Date).getTime());
return {
name: 'gpu',
fields: [
'time',
'productName',
'deviceID',
'legacy',
'gpuUtil',
'temperature',
'powerDraw',
'powerLimit',
'memUsed',
'memTotal',
'fanSpeed',
],
headers: [
t('commons.table.date'),
'GPU',
'GPU ID',
t('aiTools.gpu.legacyDevice'),
`${t('aiTools.gpu.gpuUtil')} (%)`,
`${t('aiTools.gpu.temperature')} (°C)`,
`${t('aiTools.gpu.powerUsage')} (W)`,
`${t('aiTools.gpu.powerLimit')} (W)`,
`${t('aiTools.gpu.memoryUsed')} (MiB)`,
`${t('aiTools.gpu.memoryTotal')} (MiB)`,
t('aiTools.gpu.fanSpeed'),
],
rows,
};
};
const collectTables = async (): Promise<MonitorExportTable[]> => {
const [startTime, endTime] = timeRange.value;
const jobs: Promise<void>[] = [];
const datasets: Array<Host.MonitorData> = [];
const gpuDatasets: Array<{ device: AI.ChartHide; data: AI.MonitorGPUData }> = [];
const search = (param: string, io = 'all', network = 'all') =>
loadMonitor({ param, io, network, startTime, endTime }, currentNode.value).then((res) => {
datasets.push(...(res.data || []));
});
if (form.params.some((item) => ['cpu', 'memory', 'load'].includes(item))) {
jobs.push(search('load'));
}
if (form.params.includes('io')) {
// empty io queries every device in one request; 'all' selects the stored aggregate rows only
jobs.push(search('io', form.io === ALL_DEVICES ? '' : form.io));
}
if (form.params.includes('network')) {
jobs.push(search('network', 'all', form.network === ALL_DEVICES ? '' : form.network));
}
if (form.params.includes('gpu')) {
const devices = gpuOptions.value.filter(
(device) => form.gpuDevice === ALL_DEVICES || device.value === form.gpuDevice,
);
for (const device of devices) {
jobs.push(
loadGPUMonitor(
{
deviceID: device.deviceID,
productName: device.productName,
legacy: device.legacy,
startTime,
endTime,
},
currentNode.value,
).then((res) => {
gpuDatasets.push({ device, data: res.data });
}),
);
}
}
await Promise.all(jobs);
const tables: MonitorExportTable[] = [];
if (form.params.some((item) => ['cpu', 'memory', 'load'].includes(item))) {
tables.push(buildBaseTable(datasets));
}
if (form.params.includes('io')) {
tables.push(buildIOTable(datasets));
}
if (form.params.includes('network')) {
tables.push(buildNetworkTable(datasets));
}
if (form.params.includes('gpu')) {
tables.push(buildGPUTable(gpuDatasets));
}
return tables;
};
const onExport = async () => {
if (form.params.length === 0) {
MsgWarning(t('monitor.selectMetrics'));
return;
}
if (!timeRange.value || timeRange.value.length !== 2) {
MsgWarning(t('monitor.selectTimeRange'));
return;
}
loading.value = true;
try {
await optionsReady.value;
// retry only the lists that failed on mount; a stale but non-empty gpu list is still usable
await loadOptions(true);
if (
form.params.includes('gpu') &&
form.gpuDevice === ALL_DEVICES &&
gpuOptionsFailed.value &&
gpuOptions.value.length === 0
) {
MsgWarning(t('monitor.exportOptionsFailed', ['GPU']));
return;
}
const tables = await collectTables();
if (tables.every((table) => table.rows.length === 0)) {
MsgWarning(t('commons.msg.noneData'));
return;
}
const emptyTables = tables.filter((table) => table.rows.length === 0).map((table) => tableLabel(table.name));
const truncated = await exportMonitorTables(
tables,
form.format,
`1panel-monitor-${dateFormatForName(new Date())}`,
);
if (emptyTables.length > 0) {
MsgWarning(t('monitor.exportEmptyTables', [emptyTables.join(', ')]));
}
if (truncated.length > 0) {
MsgWarning(t('monitor.exportTruncated', [truncated.map(tableLabel).join(', ')]));
}
} finally {
loading.value = false;
}
};
const optionsReady = ref<Promise<void>>(Promise.resolve());
const loadIOOptions = async () => {
try {
const res = await getIOOptions(currentNode.value);
ioOptions.value = res.data || [];
ioOptionsFailed.value = false;
} catch {
ioOptionsFailed.value = true;
}
};
const loadNetOptions = async () => {
try {
const res = await getNetworkOptions(currentNode.value);
netOptions.value = res.data || [];
netOptionsFailed.value = false;
} catch {
netOptionsFailed.value = true;
}
};
const loadGPUOptions = async () => {
try {
const res = await getGPUOptions(currentNode.value);
gpuOptions.value = (res.data?.supported ? res.data.chartHide || [] : []).map((device) => ({
...device,
value: device.deviceID || `legacy:${device.productName}`,
label: `${device.productName} · ${device.deviceID || t('aiTools.gpu.legacyDevice')}`,
}));
gpuOptionsFailed.value = false;
} catch {
gpuOptionsFailed.value = true;
}
};
const loadOptions = async (onlyFailed = false) => {
const jobs: Promise<void>[] = [];
if (!onlyFailed || ioOptionsFailed.value) {
jobs.push(loadIOOptions());
}
if (!onlyFailed || netOptionsFailed.value) {
jobs.push(loadNetOptions());
}
if (!onlyFailed || gpuOptionsFailed.value) {
jobs.push(loadGPUOptions());
}
await Promise.all(jobs);
};
onMounted(() => {
optionsReady.value = loadOptions();
});
</script>
@@ -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',