mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-09 08:00:27 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'میانبر ترمینال',
|
||||
|
||||
@@ -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: 'ターミナルへのショートカット',
|
||||
|
||||
@@ -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: '터미널 바로가기',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Быстрый доступ к терминалу',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '終端捷徑',
|
||||
|
||||
@@ -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: '终端快捷入口',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, '&').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 = [
|
||||
`<?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',
|
||||
|
||||
Reference in New Issue
Block a user