From 4b8ad670812d2249371b8e6e761c3cf43d1fdc26 Mon Sep 17 00:00:00 2001 From: dogfootman Date: Fri, 19 Dec 2025 16:05:04 +0900 Subject: [PATCH 1/3] feat: implement PMK Workloads improvements - Add hybrid loader pattern for better UX during API calls - Implement list refresh pattern for consistent state management - Add NodeGroup Info display with Id and Status fields - Add detailed Cluster Info with Name, CspName, and CspId - Improve NodeGroup editing and validation - Add desired node size controls with min/max bounds - Implement asynchronous operations for cluster/nodegroup CRUD - Replace alert messages with toast notifications - Add common provider selection component - Fix loading bar disappearing on API errors - Fix cluster status display inconsistencies - Add Tencent cluster deletion validation - Improve NodeGroup list styling and truncation --- doc/frontend/HybridLoaderPattern.md | 473 +++++++++++++++ doc/frontend/ListRefreshPattern.md | 436 +++++++++++++ front/assets/js/common/api/http.js | 341 +++++++---- .../assets/js/common/api/services/pmk_api.js | 41 +- .../js/common/utils/listRefreshPattern.js | 194 ++++++ .../pages/configuration/workspace/manage.js | 7 + front/assets/js/pages/operation/manage/pmk.js | 573 ++++++++++++++---- .../operation/manage/clustercreate.js | 294 +++++++-- .../manage/pmk_imagerecommendation.js | 2 +- .../manage/pmk_serverrecommendation.js | 20 +- .../manage/workloads/pmkworkloads.html | 7 +- .../partials/common/_provider_select.html | 12 + .../operation/manage/_clusterinfo.html | 8 + .../operation/manage/_nodegroupinfo.html | 36 +- .../manage/_pmk_serverrecommendation.html | 10 +- 15 files changed, 2129 insertions(+), 325 deletions(-) create mode 100644 doc/frontend/HybridLoaderPattern.md create mode 100644 doc/frontend/ListRefreshPattern.md create mode 100644 front/assets/js/common/utils/listRefreshPattern.js create mode 100644 front/templates/partials/common/_provider_select.html diff --git a/doc/frontend/HybridLoaderPattern.md b/doc/frontend/HybridLoaderPattern.md new file mode 100644 index 00000000..347c0505 --- /dev/null +++ b/doc/frontend/HybridLoaderPattern.md @@ -0,0 +1,473 @@ +# Hybrid Loader Pattern 가이드 + +## 개요 + +Hybrid Loader Pattern은 여러 API를 동시에 호출할 때 각각의 진행 상황을 독립적으로 표시할 수 있는 로더 시스템입니다. + +### 문제점 + +기존 시스템에서는 여러 API를 동시에 호출할 때 먼저 응답을 받는 API가 전체 페이지 로더를 닫아버려, 나머지 API가 아직 진행 중임에도 프로그레스 표시가 사라지는 문제가 있었습니다. + +### 해결책 + +세 가지 로더 타입을 제공하여 상황에 맞게 선택할 수 있습니다: +- **Page Loader**: 전체 페이지를 블로킹하는 중요한 작업 +- **Toast Loader**: 개별 API마다 독립적인 프로그레스 표시 +- **No Loader**: 백그라운드 작업, 사용자 인지 불필요 + +## Loader Type 선택 기준 + +### 🔵 PAGE LOADER (전체 페이지 로더) + +**사용 시나리오**: +- 사용자 액션으로 시작된 중요한 작업 +- 페이지 전체가 블로킹되어야 하는 작업 +- 작업 완료까지 다른 조작을 막아야 하는 경우 +- **동기적으로 결과를 기다려야 하는 조회 작업** ✨ + +**예시**: +- 생성 (Create Cluster, Create NodeGroup) +- 삭제 (Delete Cluster, Delete NodeGroup) +- 수정 (Update Configuration) +- 실행 (Start, Stop, Reboot) +- **목록 조회 (GetAllK8sCluster)** ✨ +- **상세 조회 (Getk8scluster)** ✨ +- **Refresh 버튼 클릭** ✨ + +```javascript +{ + loaderType: 'page' +} +``` + +### 🟢 TOAST LOADER (개별 프로그레스 toast) + +**사용 시나리오**: +- 백그라운드 데이터 로딩 +- **비동기적으로 독립적으로 로딩되는 부가 데이터** ✨ +- 일부 데이터 로딩이 실패해도 페이지 사용이 가능한 경우 +- 사용자가 기다리지 않아도 되는 데이터 + +**예시**: +- 모니터링 데이터 (실시간 통계) +- 대시보드 위젯 +- 백그라운드 통계 업데이트 +- 선택적 부가 정보 + +```javascript +{ + loaderType: 'toast', + progressLabel: 'Loading Monitoring Data...', + successMessage: null // 성공 메시지 표시 안 함 +} +``` + +### ⚪ NO LOADER + +**사용 시나리오**: +- 폴링(주기적 업데이트) +- 사용자가 인지할 필요 없는 백그라운드 작업 +- 실시간 상태 업데이트 +- Heartbeat, Health Check + +```javascript +{ + loaderType: 'none' +} +``` + +## 페이지별 구현 패턴 + +### 1. Loader Config 정의 + +각 페이지 상단에 `[PAGE]_LOADER_CONFIG` 객체를 정의합니다: + +```javascript +/** + * =================================================================== + * PMK WORKLOADS PAGE - LOADER STRATEGY + * =================================================================== + * 📄 Page Loader: Create, Delete, Update operations + * 🔔 Toast Loader: Data fetching (list, details, monitoring) + * ⚪ No Loader: Background status updates + * =================================================================== + */ + +const PMK_LOADER_CONFIG = { + // 생성/삭제/수정 작업 - PAGE LOADER + create: { + cluster: { loaderType: 'page' }, + nodeGroup: { loaderType: 'page' } + }, + + delete: { + cluster: { loaderType: 'page' }, + nodeGroup: { loaderType: 'page' } + }, + + update: { + cluster: { loaderType: 'page' }, + nodeGroup: { loaderType: 'page' } + }, + + // 조회 작업 - PAGE LOADER (동기 조회) + fetch: { + // 동기 조회 - 사용자가 결과를 기다려야 하는 중요한 데이터 + clusterList: { + loaderType: 'page' // GetAllK8sCluster + }, + + clusterDetail: { + loaderType: 'page' // Getk8scluster + }, + + // 비동기 조회 - 백그라운드로 독립적으로 로딩되는 부가 데이터 + monitoring: { + loaderType: 'toast', + progressLabel: 'Loading Monitoring Data...', + successMessage: null + } + }, + + // 백그라운드 작업 - NO LOADER + background: { + statusUpdate: { loaderType: 'none' }, + heartbeat: { loaderType: 'none' } + } +}; +``` + +### 2. API Helper 생성 + +Config를 사용하는 Helper 객체를 만듭니다: + +```javascript +const PmkApiHelper = { + // 조회 작업 + async getClusterList(nsId) { + return await webconsolejs["common/api/services/pmk_api"].getClusterList( + nsId, + PMK_LOADER_CONFIG.fetch.clusterList + ); + }, + + async getClusterDetail(nsId, clusterId) { + return await webconsolejs["common/api/services/pmk_api"].getCluster( + nsId, + clusterId, + PMK_LOADER_CONFIG.fetch.clusterDetail + ); + }, + + async getNodeGroups(nsId, clusterId) { + return await webconsolejs["common/api/services/pmk_api"].getNodeGroups( + nsId, + clusterId, + PMK_LOADER_CONFIG.fetch.nodeGroupList + ); + }, + + // 생성/삭제 작업 + async createCluster(nsId, data) { + return await webconsolejs["common/api/services/pmk_api"].createCluster( + nsId, + data, + PMK_LOADER_CONFIG.create.cluster + ); + }, + + async deleteCluster(nsId, clusterId) { + return await webconsolejs["common/api/services/pmk_api"].deleteCluster( + nsId, + clusterId, + PMK_LOADER_CONFIG.delete.cluster + ); + }, + + // 여러 데이터 동시 로딩 + async loadMultipleData(nsId, clusterId) { + return await Promise.all([ + this.getClusterDetail(nsId, clusterId), + this.getNodeGroups(nsId, clusterId), + webconsolejs["common/api/services/pmk_api"].getMonitoring( + nsId, + clusterId, + PMK_LOADER_CONFIG.fetch.monitoring + ) + ]); + } +}; +``` + +### 3. 기존 함수를 Helper 사용으로 변경 + +```javascript +// ❌ Before - 직접 API 호출 +export async function refreshPmkList() { + if (selectedWorkspaceProject.projectId != "") { + var respPmkList = await webconsolejs["common/api/services/pmk_api"] + .getClusterList(selectedNsId); + getPmkListCallbackSuccess(selectedProjectId, respPmkList); + } +} + +// ✅ After - Helper 사용 +export async function refreshPmkList() { + if (selectedWorkspaceProject.projectId != "") { + const config = { + fetchListData: async () => { + return await PmkApiHelper.getClusterList(selectedNsId); + }, + updateListCallback: (respPmkList) => { + getPmkListCallbackSuccess(selectedProjectId, respPmkList); + }, + // ... 나머지 config + }; + + await webconsolejs['common/utils/listRefreshPattern'].execute(config); + } +} +``` + +## 사용 예시 + +### 단일 API 호출 (Page Loader) + +```javascript +export async function deletePmk() { + // ... validation ... + + // Page Loader가 자동으로 표시됨 + const result = await PmkApiHelper.deleteCluster( + selectedNsId, + currentPmkId + ); + + if (result && result.status === 200) { + alert('Cluster deleted successfully'); + await refreshPmkList(); + } +} +``` + +### 여러 API 동시 호출 (Toast Loader) + +```javascript +export async function getSelectedPmkData() { + if (currentPmkId) { + try { + // 3개의 Toast가 동시에 표시됨 + // 각 API가 완료되면 해당 Toast만 사라짐 + const [clusterDetail, nodeGroups, monitoring] = + await PmkApiHelper.loadMultipleData(selectedNsId, currentPmkId); + + if (clusterDetail && clusterDetail.status === 200) { + setPmkInfoData(clusterDetail.data); + } + + if (nodeGroups && nodeGroups.status === 200) { + displayNodeGroupList(nodeGroups.data); + } + + if (monitoring && monitoring.status === 200) { + displayMonitoringData(monitoring.data); + } + } catch (error) { + console.error('Error loading PMK data:', error); + } + } +} +``` + +### 목록 새로고침 (Toast Loader) + +```javascript +export async function refreshPmkList() { + if (selectedWorkspaceProject.projectId != "") { + const config = { + getSelectionId: () => currentPmkId, + detailElementIds: ['cluster_info'], + detailElementsToEmpty: ['pmk_nodegroup_info_box', 'pmk_node_info_box'], + formsToClose: ['nodegroup_configuration'], + + fetchListData: async () => { + // "Loading PMK Clusters..." toast 표시 + return await PmkApiHelper.getClusterList(selectedNsId); + }, + + updateListCallback: (respPmkList) => { + getPmkListCallbackSuccess(selectedProjectId, respPmkList); + }, + + // ... 나머지 config + }; + + await webconsolejs['common/utils/listRefreshPattern'].execute(config); + } +} +``` + +## UI 표시 예시 + +### Page Loader +전체 화면을 덮는 로더: +``` +┌────────────────────────────────────┐ +│ │ +│ 🔄 Preparing Data │ +│ │ +└────────────────────────────────────┘ +``` + +### Toast Loader +화면 우측 상단에 쌓이는 독립적인 toast: +``` + ┌─────────────────────────────┐ + │ 🔄 Loading PMK Clusters... │ + └─────────────────────────────┘ + ┌─────────────────────────────┐ + │ 🔄 Loading Node Groups... │ + └─────────────────────────────┘ + ┌─────────────────────────────┐ + │ 🔄 Loading Monitoring... │ + └─────────────────────────────┘ +``` + +## 다른 페이지 적용 가이드 + +### VM Workloads 적용 예시 + +```javascript +// vm.js + +const VM_LOADER_CONFIG = { + create: { + vm: { loaderType: 'page' } + }, + + delete: { + vm: { loaderType: 'page' } + }, + + fetch: { + vmList: { + loaderType: 'toast', + progressLabel: 'Loading VMs...' + }, + vmDetail: { + loaderType: 'toast', + progressLabel: 'Loading VM Details...' + } + }, + + action: { + start: { loaderType: 'page' }, + stop: { loaderType: 'page' }, + reboot: { loaderType: 'page' } + } +}; + +const VmApiHelper = { + async getVmList(nsId) { + return await webconsolejs["common/api/services/vm_api"].getVmList( + nsId, + VM_LOADER_CONFIG.fetch.vmList + ); + }, + + async startVm(nsId, vmId) { + return await webconsolejs["common/api/services/vm_api"].startVm( + nsId, + vmId, + VM_LOADER_CONFIG.action.start + ); + } +}; +``` + +## 구현 체크리스트 + +새로운 페이지에 패턴을 적용할 때: + +- [ ] `[PAGE]_LOADER_CONFIG` 객체 정의 +- [ ] `[Page]ApiHelper` 객체 생성 +- [ ] 기존 API 호출을 Helper로 변경 +- [ ] Page Loader가 필요한 작업 확인 +- [ ] Toast Loader가 필요한 작업 확인 +- [ ] 여러 API 동시 호출 시나리오 확인 +- [ ] 테스트 (단일 API, 복수 API) + +## 주의사항 + +1. **성공 메시지**: 대부분의 조회 작업은 `successMessage: null`로 설정하여 성공 메시지를 표시하지 않습니다. + +2. **에러 처리**: Toast loader는 에러 발생 시 자동으로 에러 toast를 표시하지 않습니다. 필요시 별도 처리가 필요합니다. + +3. **동시 호출**: `Promise.all`을 사용하여 여러 API를 동시에 호출할 때 각 Toast가 독립적으로 표시됩니다. + +4. **기본값**: `loaderType`을 지정하지 않으면 기본적으로 `page` loader가 사용됩니다. + +## 기술적 구현 + +### http.js의 로직 + +```javascript +export async function commonAPIPost(url, data, attempt, options = {}) { + const loaderType = options.loaderType || 'page'; + let toastId = null; + + try { + // Loader 시작 + if (loaderType === 'toast') { + toastId = showAPIProgressToast(url, options.progressLabel); + } else if (loaderType === 'page') { + activePageLoader(); + } + + // API 호출 + const response = await axios.post(url, data); + + return response; + } catch (error) { + // 에러 처리 + throw error; + } finally { + // Loader 종료 (항상 실행) + if (loaderType === 'toast' && toastId) { + hideAPIProgressToast(toastId, success, options.successMessage); + } else if (loaderType === 'page') { + deactivePageLoader(); + } + } +} +``` + +## 트러블슈팅 + +### Toast가 표시되지 않음 + +**원인**: Toast 시스템이 초기화되지 않았거나 `webconsolejs['common/utils/toast']`가 로드되지 않음 + +**해결**: HTML에서 `toast.js`가 `http.js` 이전에 로드되는지 확인 + +### Page Loader가 닫히지 않음 + +**원인**: API 호출 중 에러가 발생했지만 `finally` 블록이 실행되지 않음 + +**해결**: `try-finally` 구조 확인 및 `deactivePageLoader()` 호출 확인 + +### 여러 Toast가 겹쳐 보임 + +**정상 동작**: Toast는 화면 우측 상단에 쌓이도록 설계되었습니다. 각 Toast는 독립적으로 사라집니다. + +## 관련 파일 + +- **유틸리티**: `front/assets/js/common/api/http.js` +- **Toast 시스템**: `front/assets/js/common/utils/toast.js` +- **적용 예시**: `front/assets/js/pages/operation/manage/pmk.js` +- **문서**: `doc/frontend/HybridLoaderPattern.md` (현재 문서) + +## 버전 히스토리 + +- **v1.0.0** (2024): 초기 구현 및 PMK 화면 적용 + diff --git a/doc/frontend/ListRefreshPattern.md b/doc/frontend/ListRefreshPattern.md new file mode 100644 index 00000000..92403b7e --- /dev/null +++ b/doc/frontend/ListRefreshPattern.md @@ -0,0 +1,436 @@ +# List Refresh Pattern 가이드 + +## 개요 + +List Refresh Pattern은 목록 화면의 일관된 refresh 동작을 제공하는 공통 패턴입니다. 이 패턴을 사용하면 다음과 같은 이점을 얻을 수 있습니다: + +- **일관성**: 모든 목록 화면에서 동일한 refresh 동작 +- **재사용성**: 설정 객체만 변경하면 어디서든 사용 가능 +- **유지보수**: 패턴 수정 시 한 곳만 변경 +- **확장성**: 새로운 화면 추가 시 설정만 정의 +- **에러 처리**: 공통 에러 처리 로직 + +## 주요 기능 + +1. **상태 저장 및 복원**: 현재 선택된 항목을 자동으로 저장하고 refresh 후 복원 +2. **UI 초기화**: 상세 영역 숨기기, 내용 비우기, 폼 닫기를 자동으로 처리 +3. **에러 처리**: 통합된 에러 처리 및 사용자 피드백 +4. **유연한 설정**: 각 화면의 특성에 맞게 설정 가능 + +## 사용 방법 + +### 1. 기본 사용법 + +```javascript +// 1단계: Config 객체 정의 +const config = { + // 필수: 현재 선택된 ID 반환 + getSelectionId: () => currentItemId, + + // 선택: 숨길 element ID 배열 + detailElementIds: ['detail_info'], + + // 선택: 비울 element ID 배열 + detailElementsToEmpty: ['detail_content', 'sub_info'], + + // 선택: 닫을 폼 ID 배열 + formsToClose: ['edit_form'], + + // 필수: 데이터 조회 함수 + fetchListData: async () => { + return await api.getList(nsId); + }, + + // 필수: 목록 업데이트 함수 + updateListCallback: (data) => { + updateTable(data); + }, + + // 선택: Row 조회 함수 + getRowById: (id) => { + try { return table.getRow(id); } + catch (e) { return null; } + }, + + // 선택: Row 선택 함수 + selectRow: (id) => { + table.selectRow(id); + }, + + // 선택: 상세 정보 표시 함수 + showDetailData: async () => { + await loadDetailData(); + }, + + // 선택: 선택 상태 초기화 함수 + clearSelectionState: () => { + currentItemId = ''; + selectedData = {}; + }, + + // 선택: 에러 메시지 + errorMessage: 'Failed to refresh list.' +}; + +// 2단계: Pattern 실행 +await webconsolejs['common/utils/listRefreshPattern'].execute(config); +``` + +### 2. Refresh 함수에 통합 + +```javascript +export async function refreshMyList() { + if (selectedWorkspaceProject.projectId != "") { + const config = getRefreshConfig(); + await webconsolejs['common/utils/listRefreshPattern'].execute(config); + } +} +``` + +## Config 옵션 상세 + +### 필수 옵션 + +#### `fetchListData: async () => Promise` +목록 데이터를 가져오는 비동기 함수입니다. + +```javascript +fetchListData: async () => { + return await webconsolejs["common/api/services/my_api"].getList(selectedNsId); +} +``` + +#### `updateListCallback: (data) => void` +가져온 데이터로 목록을 업데이트하는 함수입니다. + +```javascript +updateListCallback: (respList) => { + getListCallbackSuccess(selectedProjectId, respList); +} +``` + +### 선택 옵션 + +#### `getSelectionId: () => string` +현재 선택된 항목의 ID를 반환하는 함수입니다. 이 함수를 제공하면 refresh 후 선택 상태가 자동으로 복원됩니다. + +```javascript +getSelectionId: () => currentPmkId +``` + +#### `detailElementIds: string[]` +숨겨야 할 상세 영역 element ID 배열입니다. + +```javascript +detailElementIds: ['cluster_info', 'node_info'] +``` + +#### `detailElementsToEmpty: string[]` +내용을 비워야 할 element ID 배열입니다. + +```javascript +detailElementsToEmpty: ['pmk_nodegroup_info_box', 'pmk_node_info_box'] +``` + +#### `formsToClose: string[]` +닫아야 할 폼 element ID 배열입니다. `active` 클래스를 확인하여 열려있는 폼만 닫습니다. + +```javascript +formsToClose: ['nodegroup_configuration', 'cluster_edit_form'] +``` + +#### `getRowById: (id) => object|null` +ID로 테이블 row 객체를 가져오는 함수입니다. 선택 상태 복원에 사용됩니다. + +```javascript +getRowById: (id) => { + try { + return pmkListTable.getRow(id); + } catch (e) { + return null; + } +} +``` + +#### `selectRow: (id) => void` +테이블에서 특정 row를 선택하는 함수입니다. + +```javascript +selectRow: (id) => { + toggleRowSelection(id); +} +``` + +#### `showDetailData: async () => Promise` +선택된 항목의 상세 정보를 표시하는 비동기 함수입니다. + +```javascript +showDetailData: async () => { + await getSelectedPmkData(); +} +``` + +#### `clearSelectionState: () => void` +선택 상태를 초기화하는 함수입니다. 선택된 항목이 삭제된 경우 호출됩니다. + +```javascript +clearSelectionState: () => { + currentPmkId = ''; + currentNodeGroupName = ''; + currentProvider = ''; + selectedClusterData = {}; +} +``` + +#### `errorMessage: string` +에러 발생 시 표시할 메시지입니다. 지정하지 않으면 기본 메시지가 표시됩니다. + +```javascript +errorMessage: 'Failed to refresh PMK list. Please try again.' +``` + +## 실제 적용 예시 (PMK) + +### 전체 코드 + +```javascript +/** + * PMK 목록 새로고침 + * Refresh PMK list + */ +export async function refreshPmkList() { + if (selectedWorkspaceProject.projectId != "") { + var selectedProjectId = selectedWorkspaceProject.projectId; + var selectedNsId = selectedWorkspaceProject.nsId; + + // List Refresh Pattern 설정 + const config = { + getSelectionId: () => currentPmkId, + detailElementIds: ['cluster_info'], + detailElementsToEmpty: ['pmk_nodegroup_info_box', 'pmk_node_info_box'], + formsToClose: ['nodegroup_configuration'], + + fetchListData: async () => { + return await webconsolejs["common/api/services/pmk_api"] + .getClusterList(selectedNsId); + }, + + updateListCallback: (respPmkList) => { + getPmkListCallbackSuccess(selectedProjectId, respPmkList); + }, + + getRowById: (id) => { + try { return pmkListTable.getRow(id); } + catch (e) { return null; } + }, + + selectRow: (id) => { + toggleRowSelection(id); + }, + + showDetailData: async () => { + await getSelectedPmkData(); + }, + + clearSelectionState: () => { + currentPmkId = ''; + currentNodeGroupName = ''; + currentProvider = ''; + selectedClusterData = {}; + }, + + errorMessage: 'Failed to refresh PMK list. Please try again.' + }; + + await webconsolejs['common/utils/listRefreshPattern'].execute(config); + } +} +``` + +### 적용 시나리오 + +PMK 화면에서 다음 모든 시나리오에서 일관되게 동작합니다: + +1. **화면 최초 로드 시**: `initPmk()` → `refreshPmkList()` +2. **Refresh 아이콘 클릭**: 직접 `refreshPmkList()` 호출 +3. **NodeGroup 추가 후**: `createNode()` → `refreshPmkList()` +4. **NodeGroup 삭제 후**: `deleteNodeGroup()` → `refreshPmkList()` +5. **Cluster 삭제 후**: `deletePmk()` → `refreshPmkList()` + +## 다른 화면 적용 가이드 + +### VM Workloads 화면 적용 예시 + +```javascript +export async function refreshVmList() { + if (selectedWorkspaceProject.projectId != "") { + var selectedProjectId = selectedWorkspaceProject.projectId; + var selectedNsId = selectedWorkspaceProject.nsId; + + const config = { + getSelectionId: () => currentVmId, + detailElementIds: ['vm_detail_info'], + detailElementsToEmpty: ['vm_monitoring_box', 'vm_ssh_terminal_box'], + formsToClose: ['vm_configuration_form'], + + fetchListData: async () => { + return await webconsolejs["common/api/services/vm_api"] + .getVmList(selectedNsId); + }, + + updateListCallback: (respVmList) => { + getVmListCallbackSuccess(selectedProjectId, respVmList); + }, + + getRowById: (id) => { + try { return vmListTable.getRow(id); } + catch (e) { return null; } + }, + + selectRow: (id) => { + toggleVmRowSelection(id); + }, + + showDetailData: async () => { + await getSelectedVmData(); + }, + + clearSelectionState: () => { + currentVmId = ''; + selectedVmData = {}; + }, + + errorMessage: 'Failed to refresh VM list. Please try again.' + }; + + await webconsolejs['common/utils/listRefreshPattern'].execute(config); + } +} +``` + +### 적용 체크리스트 + +새로운 화면에 패턴을 적용할 때 다음 체크리스트를 따르세요: + +- [ ] 현재 refresh 로직 분석 +- [ ] 전역 변수 식별 (선택 상태, 상세 데이터 등) +- [ ] UI 요소 식별 (상세 영역, 폼 등) +- [ ] Config 객체 작성 +- [ ] 기존 refresh 함수를 패턴 사용 방식으로 변경 +- [ ] 모든 refresh 호출 지점에서 테스트 +- [ ] JSDoc 주석 추가 + +## 실행 흐름 + +``` +사용자 액션 (삭제/추가/새로고침) + ↓ +refreshList() 호출 + ↓ +ListRefreshPattern.execute(config) + ↓ +1. 현재 선택 ID 저장 (getSelectionId) + ↓ +2. UI 초기화 + - 상세 영역 숨기기 (detailElementIds) + - 내용 비우기 (detailElementsToEmpty) + - 폼 닫기 (formsToClose) + ↓ +3. 데이터 조회 (fetchListData) + ↓ +4. 목록 업데이트 (updateListCallback) + ↓ +5. 선택 상태 복원 + ├─ 항목 존재 → selectRow + showDetailData + └─ 항목 삭제 → clearSelectionState + ↓ +완료 +``` + +## 트러블슈팅 + +### 문제: Pattern이 undefined 에러 + +**원인**: Webpack이 유틸리티를 번들링하지 못했거나 로드 순서 문제 + +**해결**: +1. 브라우저 콘솔에서 확인: + ```javascript + console.log(webconsolejs['common/utils/listRefreshPattern']); + ``` +2. 유틸리티 파일이 올바르게 export되었는지 확인 +3. 페이지 새로고침 (Ctrl+F5) + +### 문제: 선택 상태가 복원되지 않음 + +**원인**: `getRowById`가 제대로 작동하지 않거나 ID가 변경됨 + +**해결**: +1. `getRowById` 함수가 올바르게 구현되었는지 확인 +2. try-catch로 에러를 잡고 null 반환하는지 확인 +3. ID가 refresh 전후에 동일한지 확인 + +### 문제: 상세 영역이 숨겨지지 않음 + +**원인**: Element ID가 잘못되었거나 jQuery 선택자 문제 + +**해결**: +1. Element ID가 올바른지 확인 (대소문자 구분) +2. 브라우저 개발자 도구에서 Element 확인 +3. `$('#element_id').length`로 존재 여부 확인 + +### 문제: 폼이 닫히지 않음 + +**원인**: 폼이 `active` 클래스를 사용하지 않거나 다른 토글 방식 사용 + +**해결**: +1. 폼의 실제 토글 방식 확인 +2. 필요시 `formsToClose` 대신 `resetUI`에서 커스텀 로직 추가 +3. 또는 config에 커스텀 폼 닫기 함수 추가 + +## 향후 적용 대상 화면 + +우선순위 순서로 다음 화면에 패턴을 적용할 예정입니다: + +### 1순위: VM Workloads +- 파일: `front/assets/js/pages/operation/manage/vm.js` +- 예상 작업: 1-2시간 +- 복잡도: 중간 + +### 2순위: MCI Workloads +- 파일: `front/assets/js/pages/operation/manage/mci.js` +- 예상 작업: 1-2시간 +- 복잡도: 중간 + +### 3순위: NLB Workloads +- 파일: `front/assets/js/pages/operation/manage/nlb.js` +- 예상 작업: 1시간 +- 복잡도: 낮음 + +### 4순위: Disk Management +- 파일: `front/assets/js/pages/operation/manage/disk.js` +- 예상 작업: 1시간 +- 복잡도: 낮음 + +### 5순위: Security Group Management +- 파일: `front/assets/js/pages/operation/manage/securityGroup.js` +- 예상 작업: 1-2시간 +- 복잡도: 중간 + +## 관련 파일 + +- **유틸리티**: `front/assets/js/common/utils/listRefreshPattern.js` +- **적용 예시**: `front/assets/js/pages/operation/manage/pmk.js` +- **문서**: `doc/frontend/ListRefreshPattern.md` (현재 문서) + +## 참고 사항 + +- 모든 함수는 JSDoc으로 한글/영문 병기 주석 작성 +- 코딩 규칙 준수 (2칸 들여쓰기, 작은따옴표, 세미콜론) +- 에러 처리는 항상 포함 +- 선택 옵션이지만 가능한 모든 옵션 구현 권장 + +## 버전 히스토리 + +- **v1.0.0** (2024): 초기 구현 및 PMK 화면 적용 + diff --git a/front/assets/js/common/api/http.js b/front/assets/js/common/api/http.js index 621ef113..9720141f 100644 --- a/front/assets/js/common/api/http.js +++ b/front/assets/js/common/api/http.js @@ -1,117 +1,248 @@ import axios from 'axios'; -export async function commonAPIPost(url, data, attempt) { - activePageLoader() - if (attempt === undefined) { - attempt = false; +// 활성 프로그레스 toast 추적 / Track active progress toasts +const activeProgressToasts = new Map(); + +/** + * API 호출 시작 시 개별 progress toast 표시 + * Show individual progress toast when API call starts + * + * @param {string} url - API URL + * @param {string} label - 사용자에게 표시할 레이블 / Label to display to user + * @returns {string} toastId - 생성된 toast의 ID / Created toast ID + */ +function showAPIProgressToast(url, label) { + const toastId = `api-progress-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + if (webconsolejs && webconsolejs['common/utils/toast'] && + webconsolejs['common/utils/toast'].showProgressToast) { + webconsolejs['common/utils/toast'].showProgressToast( + toastId, + label || 'Loading...' + ); + + activeProgressToasts.set(toastId, { + url, + label, + startTime: Date.now() + }); + } + + return toastId; +} + +/** + * API 완료 시 progress toast 제거 + * Hide progress toast when API completes + * + * @param {string} toastId - 제거할 toast ID / Toast ID to remove + * @param {boolean} success - 성공 여부 / Success status + * @param {string} message - 완료 메시지 (선택) / Completion message (optional) + */ +function hideAPIProgressToast(toastId, success = true, message) { + if (toastId && activeProgressToasts.has(toastId)) { + if (webconsolejs && webconsolejs['common/utils/toast']) { + // Progress toast 제거 / Remove progress toast + if (webconsolejs['common/utils/toast'].hideToast) { + webconsolejs['common/utils/toast'].hideToast(toastId); + } + + // 성공/실패 toast 표시 (선택사항) / Show success/failure toast (optional) + if (message && webconsolejs['common/util'] && webconsolejs['common/util'].showToast) { + webconsolejs['common/util'].showToast( + message, + success ? 'success' : 'error', + success ? 2000 : 5000 + ); + } } - console.log("#### commonAPIPost", ); - console.log("Request URL :", url); - console.log("Request Data :", JSON.stringify(data)); - console.log("-----------------------"); - try { - if( data === undefined || data === null) { - var response = await axios.post(url); - } else if (data.formData instanceof FormData) { - // FormData 처리 분기 - axios 사용 - console.log("FormData detected, sending with axios"); - - // pathParams가 있으면 FormData에 추가 - if (data.pathParams) { - for (const [key, value] of Object.entries(data.pathParams)) { - data.formData.append(key, value); - } - console.log("Added pathParams to FormData:", data.pathParams); - } - - // queryParams가 있으면 FormData에 추가 - if (data.queryParams) { - for (const [key, value] of Object.entries(data.queryParams)) { - data.formData.append(key, value); - } - console.log("Added queryParams to FormData:", data.queryParams); - } - - // FormData 사용 시 Content-Type 헤더를 설정하지 않음 - // 브라우저가 자동으로 boundary 정보와 함께 올바른 헤더를 설정 - var response = await axios.post(url, data.formData); - } else { - var response = await axios.post(url, data); + + activeProgressToasts.delete(toastId); + } +} + +export async function commonAPIPost(url, data, attempt, options = {}) { + // Loader Type 결정 / Determine loader type + // options.loaderType: 'page' | 'toast' | 'none' + const loaderType = options.loaderType || 'page'; // 기본값: page + let toastId = null; + + // Loader 시작 / Start loader + if (loaderType === 'toast') { + toastId = showAPIProgressToast(url, options.progressLabel); + } else if (loaderType === 'page') { + activePageLoader(); + } + // loaderType === 'none'이면 로더 표시 안 함 / No loader if 'none' + + if (attempt === undefined) { + attempt = false; + } + + console.log("#### commonAPIPost"); + console.log("Request URL :", url); + console.log("Request Data :", JSON.stringify(data)); + console.log("Loader Type :", loaderType); + console.log("-----------------------"); + + try { + if (data === undefined || data === null) { + var response = await axios.post(url); + } else if (data.formData instanceof FormData) { + // FormData 처리 분기 - axios 사용 + console.log("FormData detected, sending with axios"); + + // pathParams가 있으면 FormData에 추가 + if (data.pathParams) { + for (const [key, value] of Object.entries(data.pathParams)) { + data.formData.append(key, value); } - console.log("#### commonAPIPost Response"); - console.log("Response status : ", response.status); - console.log("Response from : ",url, response.data); - console.log("----------------------------"); - deactivePageLoader() - return response; - } catch (error) { - console.log("#### commonAPIPost Error"); - console.log("Error from : ",url, error.response ? error.response.status : error.message); - console.log("----------------------------"); - if (!attempt || attempt === undefined) { - if (error.response && error.response.status === 429) { - webconsolejs["common/util"].showToast("Too many requests. Please try again later.", 'warning'); - return error; - } - // 404 에러는 데이터가 없는 정상적인 상황이므로 토큰 갱신하지 않음 - if (error.response && error.response.status === 404) { - console.log("Resource not found (404) - this may be normal for empty data"); - return error; - } - // 401 Unauthorized는 토큰 만료 또는 인증 실패 - if (error.response && error.response.status === 401) { - const authrefreshStatus = await webconsolejs["common/cookie/authcookie"].refreshCookieAccessToken(); - if (authrefreshStatus) { - console.log("refreshCookieAccessToken success. Retrying request with refreshed token..."); - return commonAPIPost(url, data, true); - } else { - webconsolejs["common/util"].showToast("Session has expired. Please login again.", 'error'); - window.location = "/auth/login"; - return; - } - } - // 403 Forbidden은 권한 부족 - if (error.response && error.response.status === 403) { - webconsolejs["common/util"].showToast("Insufficient permissions. Please contact your administrator.", 'error'); - return error; - } - // 500 Internal Server Error는 서버 오류 - if (error.response && error.response.status === 500) { - webconsolejs["common/util"].showToast("Server error occurred. Please try again later.", 'error'); - return error; - } - // 기타 HTTP 에러 - if (error.response && (error.response.status !== 200)) { - const authrefreshStatus = await webconsolejs["common/cookie/authcookie"].refreshCookieAccessToken(); - if (authrefreshStatus) { - console.log("refreshCookieAccessToken success. Retrying request with refreshed token..."); - return commonAPIPost(url, data, true); - } else { - // 토큰 갱신 실패 시 에러 메시지만 표시하고 로그인 페이지로 리다이렉트하지 않음 - webconsolejs["common/util"].showToast("An error occurred. Please try again later.", 'error'); - return error; - } - } + console.log("Added pathParams to FormData:", data.pathParams); + } + + // queryParams가 있으면 FormData에 추가 + if (data.queryParams) { + for (const [key, value] of Object.entries(data.queryParams)) { + data.formData.append(key, value); } - deactivePageLoader(); - - // 네트워크 오류나 기타 예외 상황 처리 - if (!error.response) { - // 네트워크 오류 (서버에 연결할 수 없음) - if (error.code === 'ECONNABORTED') { - webconsolejs["common/util"].showToast("Request timeout. Please check your network connection and try again.", 'error'); - } else if (error.code === 'ERR_NETWORK') { - webconsolejs["common/util"].showToast("Network connection failed. Please check your internet connection and try again.", 'error'); - } else { - webconsolejs["common/util"].showToast("An error occurred while processing the request: " + error.message, 'error'); - } + console.log("Added queryParams to FormData:", data.queryParams); + } + + // FormData 사용 시 Content-Type 헤더를 설정하지 않음 + // 브라우저가 자동으로 boundary 정보와 함께 올바른 헤더를 설정 + var response = await axios.post(url, data.formData); + } else { + var response = await axios.post(url, data); + } + + console.log("#### commonAPIPost Response"); + console.log("Response status : ", response.status); + console.log("Response from : ", url, response.data); + console.log("----------------------------"); + + // Loader 종료 / End loader + if (loaderType === 'toast' && toastId) { + hideAPIProgressToast(toastId, true, options.successMessage); + } else if (loaderType === 'page') { + deactivePageLoader(); + } + + return response; + } catch (error) { + console.log("#### commonAPIPost Error"); + console.log("Error from : ", url, error.response ? error.response.status : error.message); + console.log("----------------------------"); + + if (!attempt || attempt === undefined) { + if (error.response && error.response.status === 429) { + webconsolejs["common/util"].showToast("Too many requests. Please try again later.", 'warning'); + // Loader 종료 / End loader + if (loaderType === 'toast' && toastId) { + hideAPIProgressToast(toastId, false); + } else if (loaderType === 'page') { + deactivePageLoader(); + } + return error; + } + // 404 에러는 데이터가 없는 정상적인 상황이므로 토큰 갱신하지 않음 + if (error.response && error.response.status === 404) { + console.log("Resource not found (404) - this may be normal for empty data"); + // Loader 종료 / End loader + if (loaderType === 'toast' && toastId) { + hideAPIProgressToast(toastId, false); + } else if (loaderType === 'page') { + deactivePageLoader(); + } + return error; + } + // 401 Unauthorized는 토큰 만료 또는 인증 실패 + if (error.response && error.response.status === 401) { + const authrefreshStatus = await webconsolejs["common/cookie/authcookie"].refreshCookieAccessToken(); + if (authrefreshStatus) { + console.log("refreshCookieAccessToken success. Retrying request with refreshed token..."); + return commonAPIPost(url, data, true, options); } else { - // HTTP 에러가 있지만 위에서 처리되지 않은 경우 - webconsolejs["common/util"].showToast("An error occurred while processing the request. (Status code: " + error.response.status + ")", 'error'); + // Loader 종료 / End loader + if (loaderType === 'toast' && toastId) { + hideAPIProgressToast(toastId, false); + } else if (loaderType === 'page') { + deactivePageLoader(); + } + webconsolejs["common/util"].showToast("Session has expired. Please login again.", 'error'); + window.location = "/auth/login"; + return; } - + } + // 403 Forbidden은 권한 부족 + if (error.response && error.response.status === 403) { + // Loader 종료 / End loader + if (loaderType === 'toast' && toastId) { + hideAPIProgressToast(toastId, false); + } else if (loaderType === 'page') { + deactivePageLoader(); + } + webconsolejs["common/util"].showToast("Insufficient permissions. Please contact your administrator.", 'error'); return error; + } + // 500 Internal Server Error는 서버 오류 + if (error.response && error.response.status === 500) { + // Loader 종료 / End loader + if (loaderType === 'toast' && toastId) { + hideAPIProgressToast(toastId, false); + } else if (loaderType === 'page') { + deactivePageLoader(); + } + webconsolejs["common/util"].showToast("Server error occurred. Please try again later.", 'error'); + return error; + } + // 기타 HTTP 에러 + if (error.response && (error.response.status !== 200)) { + const authrefreshStatus = await webconsolejs["common/cookie/authcookie"].refreshCookieAccessToken(); + if (authrefreshStatus) { + console.log("refreshCookieAccessToken success. Retrying request with refreshed token..."); + return commonAPIPost(url, data, true, options); + } else { + // Loader 종료 / End loader + if (loaderType === 'toast' && toastId) { + hideAPIProgressToast(toastId, false); + } else if (loaderType === 'page') { + deactivePageLoader(); + } + // 토큰 갱신 실패 시 에러 메시지만 표시하고 로그인 페이지로 리다이렉트하지 않음 + webconsolejs["common/util"].showToast("An error occurred. Please try again later.", 'error'); + return error; + } + } + } + + // Loader 종료 / End loader + if (loaderType === 'toast' && toastId) { + hideAPIProgressToast(toastId, false); + } else if (loaderType === 'page') { + deactivePageLoader(); + } + + // 네트워크 오류나 기타 예외 상황 처리 + if (!error.response) { + // 네트워크 오류 (서버에 연결할 수 없음) + if (error.code === 'ECONNABORTED') { + webconsolejs["common/util"].showToast("Request timeout. Please check your network connection and try again.", 'error'); + } else if (error.code === 'ERR_NETWORK') { + webconsolejs["common/util"].showToast("Network connection failed. Please check your internet connection and try again.", 'error'); + } else { + webconsolejs["common/util"].showToast("An error occurred while processing the request: " + error.message, 'error'); + } + } else { + // HTTP 에러가 있지만 위에서 처리되지 않은 경우 + if (error.response.status) { + webconsolejs["common/util"].showToast("An error occurred while processing the request. (Status code: " + error.response.status + ")", 'error'); + } else { + webconsolejs["common/util"].showToast("An error occurred while processing the request: " + error.message, 'error'); + } } + + return error; + } } export async function commonAPIPostWithoutRetry(url, data) { diff --git a/front/assets/js/common/api/services/pmk_api.js b/front/assets/js/common/api/services/pmk_api.js index 30da3227..74cb52dc 100644 --- a/front/assets/js/common/api/services/pmk_api.js +++ b/front/assets/js/common/api/services/pmk_api.js @@ -1,7 +1,7 @@ // PMK API 관련 // 받아온 project(namespace)로 PmkList GET -export async function getClusterList(nsId) { +export async function getClusterList(nsId, options = {}) { if (nsId == "") { alert("Project has not set") @@ -17,14 +17,16 @@ export async function getClusterList(nsId) { var controller = "/api/" + "mc-infra-manager/" + "GetAllK8sCluster"; const response = await webconsolejs["common/api/http"].commonAPIPost( controller, - data + data, + false, + options ) var pmkList = response.data.responseData; return pmkList } -export async function getCluster(nsId, clusterId) { +export async function getCluster(nsId, clusterId, options = {}) { // Validation: Check nsId if (!nsId || nsId === "") { webconsolejs['partials/layout/modal'].commonShowDefaultModal( @@ -55,7 +57,9 @@ export async function getCluster(nsId, clusterId) { var controller = "/api/" + "mc-infra-manager/" + "Getk8scluster"; const response = await webconsolejs["common/api/http"].commonAPIPost( controller, - data + data, + false, + options ); // error check를 위해 response를 return @@ -305,7 +309,7 @@ export async function getProviderList() { return response.data.responseData.output } -export async function getRegionList() { +export async function getRegionList(options = {}) { // let data = { // pathParams: { @@ -317,13 +321,15 @@ export async function getRegionList() { let controller = "/api/" + "mc-infra-manager/" + "RetrieveRegionListFromCsp"; let response = await webconsolejs["common/api/http"].commonAPIPost( controller, - + undefined, + false, + options ); return response.data.responseData.region } -export async function getCloudConnection() { +export async function getCloudConnection(options = {}) { // test @@ -335,7 +341,9 @@ export async function getCloudConnection() { let controller = "/api/" + "mc-infra-manager/" + "GetConnConfigList"; let response = await webconsolejs["common/api/http"].commonAPIPost( controller, - data + data, + false, + options ); return response.data.responseData.connectionconfig @@ -414,12 +422,13 @@ export async function createNode(k8sClusterId, nsId, Create_Node_Config_Arr) { data ); - // 성공 처리 - if (response && response.status === 200) { + // 성공 처리 (200 OK 또는 201 Created) + if (response && (response.status === 200 || response.status === 201)) { webconsolejs["common/util"].showToast('Node group creation request completed successfully', 'success'); return response; } else { console.error('Node creation failed:', response); + console.error('Response status:', response?.status, 'Type:', typeof response?.status); webconsolejs["common/util"].showToast('Failed to create node group', 'error'); return response; } @@ -647,7 +656,7 @@ export function calculateVmStatusCount(aPmk) { return vmStatusCountMap; } -export function pmkDelete(nsId, k8sClusterId) { +export function pmkDelete(nsId, k8sClusterId, options = {}) { // API 레벨 Validation (추가 안전장치) if (!nsId || nsId === '' || !k8sClusterId || k8sClusterId === '') { console.error('Invalid parameters for PMK deletion:', { @@ -670,12 +679,14 @@ export function pmkDelete(nsId, k8sClusterId) { let controller = '/api/' + 'mc-infra-manager/' + 'Deletek8scluster'; let response = webconsolejs['common/api/http'].commonAPIPost( controller, - data + data, + false, + options ); return response; } -export function nodeGroupDelete(nsId, k8sClusterId, k8sNodeGroupName) { +export function nodeGroupDelete(nsId, k8sClusterId, k8sNodeGroupName, options = {}) { // API 레벨 Validation (추가 안전장치) if (!nsId || nsId === '' || !k8sClusterId || k8sClusterId === '' || @@ -702,7 +713,9 @@ export function nodeGroupDelete(nsId, k8sClusterId, k8sNodeGroupName) { let controller = '/api/' + 'mc-infra-manager/' + 'Deletek8snodegroup'; let response = webconsolejs['common/api/http'].commonAPIPost( controller, - data + data, + false, + options ); return response; } diff --git a/front/assets/js/common/utils/listRefreshPattern.js b/front/assets/js/common/utils/listRefreshPattern.js new file mode 100644 index 00000000..eb92b225 --- /dev/null +++ b/front/assets/js/common/utils/listRefreshPattern.js @@ -0,0 +1,194 @@ +/** + * List Refresh Pattern 유틸리티 + * List Refresh Pattern Utility + * + * 목록 화면의 일관된 refresh 동작을 제공하는 공통 패턴 + * Provides consistent refresh behavior for list screens + * + * @module listRefreshPattern + */ + +/** + * 범용 List Refresh 패턴 실행 + * Execute universal list refresh pattern + * + * @param {Object} config - Refresh 설정 객체 / Refresh configuration object + * @param {Function} config.getSelectionId - 현재 선택된 항목 ID를 반환하는 함수 / Function to get current selection ID + * @param {Array} config.detailElementIds - 숨겨야 할 상세 영역 element ID 배열 / Array of detail element IDs to hide + * @param {Array} config.detailElementsToEmpty - 내용을 비워야 할 element ID 배열 / Array of element IDs to empty + * @param {Array} config.formsToClose - 닫아야 할 폼 element ID 배열 / Array of form element IDs to close + * @param {Function} config.fetchListData - 목록 데이터를 가져오는 async 함수 / Async function to fetch list data + * @param {Function} config.updateListCallback - 가져온 데이터로 목록을 업데이트하는 함수 / Function to update list with fetched data + * @param {Function} config.getRowById - ID로 row 객체를 가져오는 함수 / Function to get row object by ID + * @param {Function} config.selectRow - row를 선택하는 함수 / Function to select a row + * @param {Function} config.showDetailData - 선택된 항목의 상세 정보를 표시하는 async 함수 / Async function to show detail data + * @param {Function} config.clearSelectionState - 선택 상태를 초기화하는 함수 / Function to clear selection state + * @param {string} config.errorMessage - 에러 메시지 (선택사항) / Error message (optional) + * @returns {Promise} - { success: boolean, state: Object, error: Error } + */ +export async function execute(config) { + try { + // 설정 검증 / Validate configuration + if (!validateConfig(config)) { + console.error('Invalid refresh config:', config); + return { success: false, error: 'Invalid configuration' }; + } + + // 1. 현재 선택 상태 저장 / Save current selection state + const state = saveState(config); + + // 2. UI 초기화 / Reset UI + resetUI(config); + + // 3. 데이터 조회 및 업데이트 / Fetch and update data + await refreshData(config); + + // 4. 상태 복원 / Restore state + await restoreState(config, state); + + return { success: true, state }; + } catch (error) { + console.error('List refresh pattern error:', error); + handleError(config, error); + return { success: false, error }; + } +} + +/** + * 설정 객체 검증 + * Validate configuration object + * + * @param {Object} config - 검증할 설정 객체 / Configuration object to validate + * @returns {boolean} - 검증 결과 / Validation result + */ +function validateConfig(config) { + const required = ['fetchListData', 'updateListCallback']; + return required.every(key => typeof config[key] === 'function'); +} + +/** + * 현재 상태 저장 + * Save current state + * + * @param {Object} config - 설정 객체 / Configuration object + * @returns {Object} - 저장된 상태 / Saved state + */ +function saveState(config) { + return { + selectedId: config.getSelectionId ? config.getSelectionId() : null, + timestamp: Date.now() + }; +} + +/** + * UI 초기화 + * Reset UI elements + * + * @param {Object} config - 설정 객체 / Configuration object + */ +function resetUI(config) { + // 상세 영역 숨기기 / Hide detail areas + if (config.detailElementIds && Array.isArray(config.detailElementIds)) { + config.detailElementIds.forEach(id => { + $(`#${id}`).hide(); + }); + } + + // 내용 비우기 / Empty content areas + if (config.detailElementsToEmpty && Array.isArray(config.detailElementsToEmpty)) { + config.detailElementsToEmpty.forEach(id => { + $(`#${id}`).empty(); + }); + } + + // 폼 닫기 / Close forms + if (config.formsToClose && Array.isArray(config.formsToClose)) { + config.formsToClose.forEach(formId => { + const form = document.getElementById(formId); + if (form && form.classList.contains('active')) { + webconsolejs['partials/layout/navigatePages'].toggleSubElement(form); + } + }); + } +} + +/** + * 데이터 조회 및 목록 업데이트 + * Fetch data and update list + * + * @param {Object} config - 설정 객체 / Configuration object + */ +async function refreshData(config) { + const data = await config.fetchListData(); + config.updateListCallback(data); +} + +/** + * 선택 상태 복원 + * Restore selection state + * + * @param {Object} config - 설정 객체 / Configuration object + * @param {Object} state - 저장된 상태 / Saved state + */ +async function restoreState(config, state) { + if (!state.selectedId || !config.getRowById) { + return; + } + + const row = config.getRowById(state.selectedId); + + if (row) { + // 항목이 여전히 존재하면 선택 복원 / Item still exists, restore selection + if (config.selectRow) { + config.selectRow(state.selectedId); + } + if (config.showDetailData) { + await config.showDetailData(); + } + } else { + // 항목이 삭제되었으면 상태 초기화 / Item deleted, clear state + if (config.clearSelectionState) { + config.clearSelectionState(); + } + } +} + +/** + * 에러 처리 + * Handle error + * + * @param {Object} config - 설정 객체 / Configuration object + * @param {Error} error - 에러 객체 / Error object + */ +function handleError(config, error) { + const message = config.errorMessage || 'Failed to refresh list. Please try again.'; + if (webconsolejs && webconsolejs['common/util'] && webconsolejs['common/util'].showToast) { + webconsolejs['common/util'].showToast(message, 'error'); + } else { + console.error(message, error); + } +} + +// Export functions +export const ListRefreshPattern = { + execute, + validateConfig, + saveState, + resetUI, + refreshData, + restoreState, + handleError +}; + +// Webpack에 등록 / Register to webpack +if (typeof webconsolejs === 'undefined') { + window.webconsolejs = {}; +} +if (typeof webconsolejs['common/utils/listRefreshPattern'] === 'undefined') { + webconsolejs['common/utils/listRefreshPattern'] = ListRefreshPattern; +} + +// Default export +export default ListRefreshPattern; + + diff --git a/front/assets/js/pages/configuration/workspace/manage.js b/front/assets/js/pages/configuration/workspace/manage.js index e4326785..ad727a03 100644 --- a/front/assets/js/pages/configuration/workspace/manage.js +++ b/front/assets/js/pages/configuration/workspace/manage.js @@ -254,6 +254,13 @@ export async function deleteWorkspace() { "workspaceId": workspace.workspace_id, } }; + + // const data = { + // request: { + // "name": name, + // "description": desc, + // } + // } let controller = "/api/" + "deleteworkspace"; let response = await webconsolejs["common/api/http"].commonAPIPost( controller, diff --git a/front/assets/js/pages/operation/manage/pmk.js b/front/assets/js/pages/operation/manage/pmk.js index 1becbac5..2f7b9474 100644 --- a/front/assets/js/pages/operation/manage/pmk.js +++ b/front/assets/js/pages/operation/manage/pmk.js @@ -1,9 +1,90 @@ import { TabulatorFull as Tabulator } from "tabulator-tables"; +/** + * =================================================================== + * PMK WORKLOADS PAGE - LOADER STRATEGY + * =================================================================== + * 📄 Page Loader: Create, Delete, Update, Synchronous Fetch operations + * 🔔 Toast Loader: Asynchronous background data loading + * ⚪ No Loader: Background status updates + * =================================================================== + */ + +// PMK Loader Configuration / PMK 로더 설정 +const PMK_LOADER_CONFIG = { + // 생성 작업 / Create operations + create: { + cluster: { loaderType: 'page' }, + nodeGroup: { loaderType: 'page' } + }, + + // 삭제 작업 / Delete operations + delete: { + cluster: { loaderType: 'page' }, + nodeGroup: { loaderType: 'page' } + }, + + // 조회 작업 / Fetch operations + fetch: { + // 동기 조회 - Page Loader (사용자가 결과를 기다려야 함) + clusterList: { + loaderType: 'page' // 변경: GetAllK8sCluster는 동기적으로 기다려야 함 + }, + clusterDetail: { + loaderType: 'page' // 변경: Getk8scluster는 동기적으로 기다려야 함 + }, + + // 비동기 조회 - Toast Loader (백그라운드 데이터) + monitoring: { + loaderType: 'toast', + progressLabel: 'Loading Monitoring Data...', + successMessage: null + } + } +}; + +// PMK API Helper / PMK API 헬퍼 +const PmkApiHelper = { + // 조회 작업 / Fetch operations + async getClusterList(nsId) { + return await webconsolejs["common/api/services/pmk_api"].getClusterList( + nsId, + PMK_LOADER_CONFIG.fetch.clusterList + ); + }, + + async getClusterDetail(nsId, clusterId) { + return await webconsolejs["common/api/services/pmk_api"].getCluster( + nsId, + clusterId, + PMK_LOADER_CONFIG.fetch.clusterDetail + ); + }, + + // 삭제 작업 / Delete operations + async deleteCluster(nsId, clusterId) { + return await webconsolejs["common/api/services/pmk_api"].pmkDelete( + nsId, + clusterId, + PMK_LOADER_CONFIG.delete.cluster + ); + }, + + async deleteNodeGroup(nsId, clusterId, nodeGroupName) { + return await webconsolejs["common/api/services/pmk_api"].nodeGroupDelete( + nsId, + clusterId, + nodeGroupName, + PMK_LOADER_CONFIG.delete.nodeGroup + ); + } +}; + // navBar에 있는 object인데 직접 handling( onchange) $("#select-current-project").on('change', async function () { let project = { "Id": this.value, "Name": this.options[this.selectedIndex].text, "NsId": this.options[this.selectedIndex].text } webconsolejs["common/api/services/workspace_api"].setCurrentProject(project)// 세션에 저장 + // Using direct API call with default page loader for project change var respPmkList = await webconsolejs["common/api/services/pmk_api"].getClusterList(project.NsId); getPmkListCallbackSuccess(project.NsId, respPmkList); }) @@ -100,21 +181,82 @@ async function initPmk() { } // pmk목록 조회. init, refresh 에서 사용 +/** + * PMK 목록 새로고침 + * Refresh PMK list + * + * List Refresh Pattern을 사용하여 일관된 refresh 동작 제공 + * Uses List Refresh Pattern for consistent refresh behavior + * + * 적용 시나리오 / Applied scenarios: + * - 화면 최초 로드 시 / Initial screen load + * - Refresh 아이콘 클릭 시 / Refresh icon click + * - NodeGroup 추가/삭제 후 / After NodeGroup add/delete + * - Cluster 삭제 후 / After Cluster delete + */ export async function refreshPmkList() { - if (selectedWorkspaceProject.projectId != "") { - var selectedProjectId = selectedWorkspaceProject.projectId; - var selectedNsId = selectedWorkspaceProject.nsId; + if (selectedWorkspaceProject.projectId != "") { + var selectedProjectId = selectedWorkspaceProject.projectId; + var selectedNsId = selectedWorkspaceProject.nsId; + + // List Refresh Pattern 설정 / List Refresh Pattern configuration + const config = { + // 현재 선택 ID 가져오기 / Get current selection ID + getSelectionId: () => currentPmkId, + + // 숨길 상세 영역 / Detail areas to hide + detailElementIds: ['cluster_info'], + + // 내용을 비울 영역 / Areas to empty + detailElementsToEmpty: ['pmk_nodegroup_info_box', 'pmk_node_info_box'], - //getPmkList();// project가 선택되어 있으면 pmk목록을 조회한다. - var respPmkList = await webconsolejs["common/api/services/pmk_api"].getClusterList(selectedNsId); + // 닫을 폼 / Forms to close + formsToClose: ['nodegroup_configuration'], + + // 목록 데이터 조회 / Fetch list data + fetchListData: async () => { + return await PmkApiHelper.getClusterList(selectedNsId); + }, + + // 목록 업데이트 / Update list + updateListCallback: (respPmkList) => { getPmkListCallbackSuccess(selectedProjectId, respPmkList); + }, - if (currentPmkId != undefined) { - toggleRowSelection(currentPmkId) - getSelectedPmkData() + // Row 가져오기 / Get row by ID + getRowById: (id) => { + try { + return pmkListTable.getRow(id); + } catch (e) { + return null; } - //////////////////// pmkId를 set하고 조회 완료. //////////////// - } + }, + + // Row 선택 / Select row + selectRow: (id) => { + toggleRowSelection(id); + }, + + // 상세 정보 표시 / Show detail data + showDetailData: async () => { + await getSelectedPmkData(); + }, + + // 선택 상태 초기화 / Clear selection state + clearSelectionState: () => { + currentPmkId = ''; + currentNodeGroupName = ''; + currentProvider = ''; + selectedClusterData = {}; + }, + + // 에러 메시지 / Error message + errorMessage: 'Failed to refresh PMK list. Please try again.' + }; + + // Pattern 실행 / Execute pattern + await webconsolejs['common/utils/listRefreshPattern'].execute(config); + } } // getPmkList 호출 성공 시 @@ -146,6 +288,10 @@ function mappingTablePmkData(totalPmkListObj) { const securityGroup = (network.SecurityGroupIIDs && network.SecurityGroupIIDs[0] && network.SecurityGroupIIDs[0].SystemId) || "N/A"; const version = item.spiderViewK8sClusterDetail?.Version || "N/A"; const nodeGroupCount = item.spiderViewK8sClusterDetail?.NodeGroupList?.length || 0; + + // Status 직접 사용 (Cluster Info와 동일하게) + const clusterStatus = item.spiderViewK8sClusterDetail?.Status || "N/A"; + return { name: item.name, id: item.id, @@ -157,6 +303,7 @@ function mappingTablePmkData(totalPmkListObj) { // TODO : ima, provider api res 변경되면 수정 providerImg: item.connectionConfig.providerName || "", // providerImg 값을 추가해야 함 (필요시) provider: item.connectionConfig.providerName || "N/A", + status: clusterStatus, vpc: vpc, subnet: subnet, securitygroup: securityGroup, @@ -174,7 +321,7 @@ export async function getSelectedPmkData() { var selectedNsId = selectedWorkspaceProject.nsId; try { - var pmkResp = await webconsolejs["common/api/services/pmk_api"].getCluster(selectedNsId, currentPmkId); + var pmkResp = await PmkApiHelper.getClusterDetail(selectedNsId, currentPmkId); // Check if pmkResp exists if (!pmkResp) { @@ -218,7 +365,7 @@ export async function getSelectedPmkData() { } // pmk 삭제 -export function deletePmk() { +export async function deletePmk() { // Validation 1: PMK가 선택되었는지 확인 if (!currentPmkId || currentPmkId === '') { webconsolejs['partials/layout/modal'].commonShowDefaultModal( @@ -238,12 +385,71 @@ export function deletePmk() { return; } - // Validation 통과 후 API 호출 - webconsolejs['common/api/services/pmk_api'].pmkDelete(selectedNsId, currentPmkId); + // Validation 3: Tencent 클러스터의 경우 NodeGroup이 없어야 삭제 가능 + if (currentProvider && currentProvider.toLowerCase() === 'tencent') { + // selectedClusterData에서 NodeGroup 목록 확인 + var nodeGroupList = selectedClusterData?.responseData?.spiderViewK8sClusterDetail?.NodeGroupList || + selectedClusterData?.spiderViewK8sClusterDetail?.NodeGroupList || + []; + + if (Array.isArray(nodeGroupList) && nodeGroupList.length > 0) { + webconsolejs['partials/layout/modal'].commonShowDefaultModal( + 'Tencent Cluster Delete Restriction', + 'Tencent clusters can only be deleted when there are no NodeGroups.
' + + 'Please delete all NodeGroups first.

' + + 'Current NodeGroups: ' + nodeGroupList.length + '' + ); + return; + } + } + + // 삭제 요청만 보내고 결과를 기다리지 않음 (fire and forget) + PmkApiHelper.deleteCluster( + selectedNsId, + currentPmkId + ); + + // 즉시 Toast 메시지 표시 + webconsolejs['common/util'].showToast('Cluster deletion request has been sent', 'info'); + + // 전역 변수 초기화 + currentPmkId = ''; + currentNodeGroupName = ''; + currentProvider = ''; + selectedClusterData = {}; + + // PMK 상세 정보 초기화 + $('#cluster_info_name').text('N/A'); + $('#cluster_info_version').text('N/A'); + $('#cluster_info_status').text('N/A'); + $('#cluster_info_vpc').text('N/A'); + $('#cluster_info_subnet').text('N/A'); + $('#cluster_info_securitygroup').text('N/A'); + $('#cluster_info_cloudconnection').text('N/A'); + $('#cluster_info_endpoint').text('N/A'); + + // NodeGroup List 초기화 + $('#pmk_nodegroup_info_box').empty(); + + // Node 상세 정보 초기화 + $('#pmk_node_info_box').empty(); + + // NodeGroup Info 영역 초기화 및 숨기기 + clearServerInfo(); + const nodeGroupInfoDiv = document.getElementById("nodeGroup_info"); + if (nodeGroupInfoDiv && nodeGroupInfoDiv.classList.contains("active")) { + webconsolejs["partials/layout/navigatePages"].toggleElement(nodeGroupInfoDiv); + } + + // Cluster Info 영역 숨기기 (초기 화면처럼) + $('#cluster_info').hide(); + + // PMK 목록 새로고침 + await refreshPmkList(); } // nodegroup 삭제 -export function deleteNodeGroup() { +export async function deleteNodeGroup() { // Validation 1: NodeGroup이 선택되었는지 확인 if (!currentNodeGroupName || currentNodeGroupName === '') { webconsolejs['partials/layout/modal'].commonShowDefaultModal( @@ -272,16 +478,38 @@ export function deleteNodeGroup() { return; } - // Validation 통과 후 API 호출 - webconsolejs['common/api/services/pmk_api'].nodeGroupDelete( + // 삭제 요청만 보내고 결과를 기다리지 않음 (fire and forget) + PmkApiHelper.deleteNodeGroup( selectedNsId, currentPmkId, currentNodeGroupName ); + + // 즉시 메시지 표시 + webconsolejs['common/util'].showToast('NodeGroup deletion request has been sent', 'info'); + + // 선택된 NodeGroup 정보 초기화 + currentNodeGroupName = ''; + + // Node 상세 정보 초기화 + $('#pmk_node_info_box').empty(); + + // NodeGroup Info 영역 초기화 및 숨기기 + clearServerInfo(); + const nodeGroupInfoDiv = document.getElementById("nodeGroup_info"); + if (nodeGroupInfoDiv && nodeGroupInfoDiv.classList.contains("active")) { + webconsolejs["partials/layout/navigatePages"].toggleElement(nodeGroupInfoDiv); + } + + // PMK 목록 새로고침 (ListRefreshPattern이 자동으로 상세 정보 표시) + await refreshPmkList(); } // 클릭한 pmk의 info값 세팅 function setPmkInfoData(pmkData) { + // Cluster Info 영역 표시 + $('#cluster_info').show(); + var clusterData = pmkData.responseData; var clusterDetailData = clusterData.spiderViewK8sClusterDetail; var pmkNetwork = clusterDetailData?.Network || {}; @@ -293,8 +521,10 @@ function setPmkInfoData(pmkData) { try { - var pmkName = clusterData.name; - var pmkID = clusterData.id + // Name, CspName, CspId 구분 + var pmkName = clusterData.name || "N/A"; + var pmkCspName = clusterDetailData?.IId?.NameId || "N/A"; + var pmkCspId = clusterDetailData?.IId?.SystemId || "N/A"; var pmkVersion = clusterDetailData?.Version || "N/A"; pmkStatus = clusterDetailData?.Status || "N/A"; @@ -315,7 +545,8 @@ function setPmkInfoData(pmkData) { // var totalNodeGroupCount = (clusterDetailData.NodeGroupList == null) ? 0 : clusterDetailData.NodeGroupList.length; $("#cluster_info_name").text(pmkName); - // $("#cluster_info_name").text(pmkName + " / " + pmkID); + $("#cluster_info_cspname").text(pmkCspName); + $("#cluster_info_cspid").text(pmkCspId); $("#cluster_info_version").text(pmkVersion); $("#cluster_info_status").text(pmkStatus); @@ -370,7 +601,7 @@ function displayNodeGroupStatusList(pmkID, clusterProvider, clusterData) { nodeGroupList.forEach((aNodeGroup) => { var nodeID = aNodeGroup.IId.SystemId; - var nodeName = aNodeGroup.IId.name; + var nodeName = aNodeGroup.IId.NameId; var nodeStatus = aNodeGroup.Status; if (clusterProvider === "azure") { @@ -379,20 +610,34 @@ function displayNodeGroupStatusList(pmkID, clusterProvider, clusterData) { } var nodeStatusClass = webconsolejs["common/api/services/pmk_api"].getVmStatusStyleClass(nodeStatus); + // 텍스트 길이 제한 (10자 초과 시 ... 표시) + var displayName = nodeName.length > 10 ? nodeName.substring(0, 10) + '...' : nodeName; + nodeLi += `
  • + style="display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + padding: 10px 15px; + min-width: 150px; + min-height: 60px; + cursor: pointer;" + onclick="webconsolejs['pages/operation/manage/pmk'].toggleNodeCheck('${pmkID}', '${nodeID}')" + title="${nodeName}"> - ${nodeID} + ${displayName}
  • `; @@ -481,6 +726,8 @@ export async function nodeGroupDetailInfo(pmkID, aNodeObject, nodeID) { var ngName = nodeGroupDetail.IId.NameId || nodeGroupDetail.IId.SystemId || aNode.cspResourceId currentNodeGroupName = ngName + var ngId = aNode.cspResourceId || nodeGroupDetail.IId.SystemId || 'N/A' + var ngStatus = aNode.status || 'N/A' var ngImage = nodeGroupDetail.ImageIID.NameId || "AL2023_x86_64_STANDARD" var ngSpec = nodeGroupDetail.VMSpecName || "t3.medium" @@ -495,6 +742,8 @@ export async function nodeGroupDetailInfo(pmkID, aNodeObject, nodeID) { // Info SET $("#ng_info_name").text(ngName) + $("#ng_info_id").text(ngId) + $("#ng_info_status").text(ngStatus) $("#ng_info_image").text(ngImage) $("#ng_info_spec").text(ngSpec) @@ -529,6 +778,46 @@ function displayNodeStatusList(nodeData) { } } +// Cluster Info 초기화 +function clearClusterInfo() { + // Cluster Info 필드 초기화 + $("#cluster_info_name").text("N/A"); + $("#cluster_info_cspname").text("N/A"); + $("#cluster_info_cspid").text("N/A"); + $("#cluster_info_version").text("N/A"); + $("#cluster_info_status").text("N/A"); + $("#cluster_info_vpc").text("N/A"); + $("#cluster_info_subnet").text("N/A"); + $("#cluster_info_securitygroup").text("N/A"); + $("#cluster_info_cloudconnection").text("N/A"); + $("#cluster_info_endpoint").text("N/A"); +} + +// NodeGroup List & Info 초기화 +function clearNodeGroupInfo() { + // NodeGroup 선택 상태 초기화 + currentNodeGroupName = ''; + + // NodeGroup List 영역 비우기 + $('#pmk_nodegroup_info_box').empty(); + + // Node 목록 비우기 + $('#pmk_node_info_box').empty(); + + // NodeGroup Info 초기화 (clearServerInfo의 NodeGroup 부분) + $("#ng_info_name").text(""); + $("#ng_info_id").text(""); + $("#ng_info_status").text(""); + $("#ng_info_image").text(""); + $("#ng_info_spec").text(""); + $("#ng_info_keypair").text(""); + $("#ng_info_desirednodesize").text(""); + $("#ng_info_nodesize").text(""); + $("#ng_info_autoscaling").text(""); + $("#ng_info_rootdisktype").text(""); + $("#ng_info_rootdisksize").text(""); +} + // vm 세부 정보 초기화 function clearServerInfo() { @@ -538,6 +827,19 @@ function clearServerInfo() { $("#server_info_name").val("") $("#server_info_desc").val("") + // NodeGroup Info 초기화 + $("#ng_info_name").text("") + $("#ng_info_id").text("") + $("#ng_info_status").text("") + $("#ng_info_image").text("") + $("#ng_info_spec").text("") + $("#ng_info_keypair").text("") + $("#ng_info_desirednodesize").text("") + $("#ng_info_nodesize").text("") + $("#ng_info_autoscaling").text("") + $("#ng_info_rootdisktype").text("") + $("#ng_info_rootdisksize").text("") + // ip information $("#server_info_public_ip").val("") $("#server_detail_info_public_ip_text").text("") @@ -749,11 +1051,37 @@ function initPmkTable() { headerSort: false, width: 60, }, + { + title: "ProviderImg", + field: "providerImg", + formatter: providerFormatter, + vertAlign: "middle", + hozAlign: "center", + headerSort: false, + }, + { + title: "Status", + field: "status", + vertAlign: "middle", + hozAlign: "center", + }, { title: "Name", field: "name", vertAlign: "middle" }, + { + title: "Node Group", + field: "nodegroup", + vertAlign: "middle", + hozAlign: "center", + maxWidth: 150, + }, + { + title: "VPC", + field: "vpc", + vertAlign: "middle" + }, { title: "Id", field: "id", @@ -774,25 +1102,12 @@ function initPmkTable() { field: "systemMessage", visible: false }, - { - title: "ProviderImg", - field: "providerImg", - formatter: providerFormatter, - vertAlign: "middle", - hozAlign: "center", - headerSort: false, - }, { title: "Provider", field: "provider", formatter: providerFormatterString, visible: false }, - { - title: "VPC", - field: "vpc", - vertAlign: "middle" - }, { title: "Subnet", field: "subnet", @@ -808,13 +1123,6 @@ function initPmkTable() { field: "version", vertAlign: "middle", visible: false, - }, - { - title: "Node Group", - field: "nodegroup", - vertAlign: "middle", - hozAlign: "center", - maxWidth: 150, } ]; @@ -826,11 +1134,17 @@ function initPmkTable() { // vmid 초기화 for vmlifecycle // selectedClusterId = "" + // 1. 기존 UI 먼저 초기화 + clearClusterInfo(); + clearNodeGroupInfo(); + + // 2. 새로운 PMK ID 설정 currentPmkId = row.getCell("id").getValue(); - // 표에서 선택된 PmkInfo + + // 3. 표에서 선택된 PmkInfo 조회 getSelectedPmkData() - // Cluster Terminal 버튼 상태 업데이트 + // 4. Cluster Terminal 버튼 상태 업데이트 updateClusterRemoteCmdButtonState(); }); @@ -1017,21 +1331,9 @@ export async function initFormDynamic() { // Dynamic 폼용 데이터 직접 로드 async function loadFormDynamicData() { try { - // Provider 목록 로드 - const providerList = await webconsolejs["common/api/services/pmk_api"].getProviderList(); - if (providerList && Array.isArray(providerList)) { - const sortedProviders = providerList.map(str => str.toUpperCase()).sort(); - - let html = ''; - sortedProviders.forEach(item => { - html += ``; - }); - - $("#cluster_provider_dynamic").empty().append(html); - } - - // Region 목록 로드 - const regionList = await webconsolejs["common/api/services/pmk_api"].getRegionList(); + // Provider 목록은 HTML partial component로 이미 렌더링됨 + // Region 목록 로드 (백그라운드, 로더 없음) + const regionList = await webconsolejs["common/api/services/pmk_api"].getRegionList({ loaderType: 'none' }); if (regionList && Array.isArray(regionList)) { let html = ''; regionList.forEach(region => { @@ -1044,8 +1346,8 @@ async function loadFormDynamicData() { $("#cluster_region_dynamic").empty().append(html); } - // Cloud Connection 목록 로드 - const cloudConnection = await webconsolejs["common/api/services/pmk_api"].getCloudConnection(); + // Cloud Connection 목록 로드 (백그라운드, 로더 없음) + const cloudConnection = await webconsolejs["common/api/services/pmk_api"].getCloudConnection({ loaderType: 'none' }); if (cloudConnection && Array.isArray(cloudConnection)) { const connectionNames = cloudConnection.map(item => item.configName).sort(); @@ -1093,8 +1395,8 @@ async function updateFormDynamicConfigurationFiltering() { // provider 선택시 region, connection filtering if (selectedProvider !== "" && selectedRegion === "") { try { - // Region 필터링 - 선택된 Provider의 Region만 표시 - const regionList = await webconsolejs["common/api/services/pmk_api"].getRegionList(); + // Region 필터링 - 선택된 Provider의 Region만 표시 (백그라운드, 로더 없음) + const regionList = await webconsolejs["common/api/services/pmk_api"].getRegionList({ loaderType: 'none' }); if (regionList && Array.isArray(regionList)) { const filteredRegions = regionList.filter(region => region.ProviderName && region.ProviderName.toUpperCase() === selectedProvider @@ -1111,8 +1413,8 @@ async function updateFormDynamicConfigurationFiltering() { $("#cluster_region_dynamic").empty().append(html); } - // Connection 필터링 - 선택된 Provider의 Connection만 표시 - const cloudConnection = await webconsolejs["common/api/services/pmk_api"].getCloudConnection(); + // Connection 필터링 - 선택된 Provider의 Connection만 표시 (백그라운드, 로더 없음) + const cloudConnection = await webconsolejs["common/api/services/pmk_api"].getCloudConnection({ loaderType: 'none' }); if (cloudConnection && Array.isArray(cloudConnection)) { const lowerSelectedProvider = selectedProvider.toLowerCase(); const filteredConnections = cloudConnection.filter(connection => @@ -1145,7 +1447,7 @@ async function updateFormDynamicConfigurationFiltering() { const regionName = selectedRegion.replace(cspRegex, '').trim(); if (provider && regionName) { - const cloudConnection = await webconsolejs["common/api/services/pmk_api"].getCloudConnection(); + const cloudConnection = await webconsolejs["common/api/services/pmk_api"].getCloudConnection({ loaderType: 'none' }); if (cloudConnection && Array.isArray(cloudConnection)) { // Provider + Region으로 정확한 Connection 필터링 const filteredConnections = cloudConnection.filter(connection => { @@ -1224,7 +1526,7 @@ export async function deployPmkDynamic() { // 필수 필드 검증 if (!clusterData.name || !clusterData.provider || !clusterData.region || !clusterData.connection) { - alert("please fill in all required fields"); + webconsolejs['common/util'].showToast('Please fill in all required fields', 'warning'); return; } @@ -1242,7 +1544,7 @@ export async function deployPmkDynamic() { commonSpec = $("#nodegroup_commonSpecId_dynamic").val(); commonImage = $("#nodegroup_image_dynamic").val(); if (!commonSpec) { - alert("please select NodeGroup spec"); + webconsolejs['common/util'].showToast('Please select NodeGroup spec', 'warning'); return; } } else { @@ -1255,8 +1557,12 @@ export async function deployPmkDynamic() { commonImage = "default"; break; case 'alibaba': - commonSpec = "alibaba+ap-northeast-2+ecs.g6e.xlarge"; - commonImage = "alibaba+ubuntu_22_04_arm64_20g_alibase_20250625.vhd"; + //commonSpec = "alibaba+ap-northeast-2+ecs.g6e.xlarge";// tb에 미등록된 spec임. + commonSpec = "alibaba+ap-northeast-2+ecs.t6-c1m4.xlarge"; + //commonImage = "alibaba+ubuntu_22_04_arm64_20g_alibase_20250625.vhd"; + //commonImage = "alibaba+ubuntu_20_04_arm64_20g_alibase_20250625.vhd"; + commonImage = "ubuntu_20_04_arm64_20g_alibase_20250625.vhd"; + //commonImage = "alibaba+ubuntu_22_04_x64_20G_alibase_20250722.vhd"; break; case 'azure': commonSpec = "azure+koreacentral+standard_b4ms"; @@ -1266,6 +1572,10 @@ export async function deployPmkDynamic() { commonSpec = "nhncloud+kr1+m2.c4m8"; commonImage = "nhncloud+kr1+ubuntu20.04container"; break; + case 'tencent': + commonSpec = "tencent+ap-seoul+s5.medium2"; + commonImage = "img-487zeit5"; + break; default: // 기타 CSP는 빈값으로 설정 commonSpec = ""; @@ -1274,14 +1584,14 @@ export async function deployPmkDynamic() { } } - // 사전 검증 API 호출 + // 사전 검증 API 호출 (동기 - 결과 확인 필요) const checkResult = await webconsolejs["common/api/services/pmk_api"].checkK8sClusterDynamic( selectedWorkspaceProject.nsId, commonSpec ); if (!checkResult || checkResult.status !== 200) { - alert("failed to pre-validate. please check the settings"); + webconsolejs['common/util'].showToast('Failed to pre-validate. Please check the settings', 'error'); return; } @@ -1316,64 +1626,89 @@ export async function deployPmkDynamic() { if (isNodeGroupVisible) { // NodeGroup 필수 필드 검증 if (!createData.nodeGroupName) { - alert("please input NodeGroup name"); + webconsolejs['common/util'].showToast('Please input NodeGroup name', 'warning'); return; } } - // 동적 클러스터 생성 API 호출 - const result = await webconsolejs["common/api/services/pmk_api"].createK8sClusterDynamic( + // available k8sversion 조회 + if(clusterData.provider.toLowerCase() === 'alibaba'){ + //createData.k8sVersion = "1.33.3-aliyun.1"; + //createData.k8sVersion = "1.33";//(사용못함 format 안맞음) + //createData.k8sVersion = "1.31.9-aliyun.1";// + //createData.k8sVersion = "1.22.15-aliyun.1"; + // createData.k8sVersion = "1.32.7-aliyun.1"; + createData.k8sVersion = "1.32.7-aliyun.1"; + } + // const k8sVersionList = await webconsolejs["common/api/services/pmk_api"].getAvailableK8sVersionList( + // selectedWorkspaceProject.nsId + // ); + // if (k8sVersionList && k8sVersionList.status === 200) { + // console.log(k8sVersionList); + // } + // // 가져온 k8sversion 중 가장 최신 버전 선택 + // if (k8sVersionList && k8sVersionList.data && k8sVersionList.data.responseData && k8sVersionList.data.responseData.length > 0) { + // const latestK8sVersion = k8sVersionList.data.responseData[0]; + // if (!latestK8sVersion) { + // if(clusterData.provider.toLowerCase() === 'alibaba'){ + // latestK8sVersion = "1.33.3-aliyun.1"; + // } + // }else{ + // createData.k8sVersion = latestK8sVersion; + // } + // } + + // 동적 클러스터 생성 API 호출 (비동기 - 결과를 기다리지 않음) + webconsolejs["common/api/services/pmk_api"].createK8sClusterDynamic( selectedWorkspaceProject.nsId, createData ); - if (result && result.status === 200) { - alert("Cluster created successfully"); - - // 폼 초기화 - $("#cluster_name_dynamic").val(""); - $("#cluster_desc_dynamic").val(""); - $("#cluster_provider_dynamic").val(""); - $("#cluster_region_dynamic").val(""); - $("#cluster_cloudconnection_dynamic").val(""); - - // NodeGroup 폼이 표시되어 있었다면 초기화 - if (isNodeGroupVisible) { - $("#nodegroup_name_dynamic").val(""); - $("#nodegroup_spec_dynamic").val(""); - $("#nodegroup_provider_dynamic").val(""); - $("#nodegroup_connectionName_dynamic").val(""); - $("#nodegroup_commonSpecId_dynamic").val(""); - $("#nodegroup_image_dynamic").val(""); - $("#nodegroup_minnodesize_dynamic").val(""); - $("#nodegroup_maxnodesize_dynamic").val(""); - $("#nodegroup_autoscaling_dynamic").val(""); - $("#nodegroup_rootdisk_dynamic").val(""); - $("#nodegroup_rootdisksize_dynamic").val(""); - $("#nodegroup_desirednodesize_dynamic").val("1"); - - // NodeGroup 폼 숨기기 - hideNodeGroupFormDynamic(); - } + // 즉시 Toast 메시지 표시 + webconsolejs['common/util'].showToast('Cluster creation request has been sent', 'info'); - // Create Cluster 카드의 Deploy 버튼 표시 - $("#createcluster .card-footer").show(); + // 폼 초기화 + $("#cluster_name_dynamic").val(""); + $("#cluster_desc_dynamic").val(""); + $("#cluster_provider_dynamic").val(""); + $("#cluster_region_dynamic").val(""); + $("#cluster_cloudconnection_dynamic").val(""); - // PMK 목록 새로고침 - await refreshPmkList(); + // NodeGroup 폼이 표시되어 있었다면 초기화 + if (isNodeGroupVisible) { + $("#nodegroup_name_dynamic").val(""); + $("#nodegroup_spec_dynamic").val(""); + $("#nodegroup_provider_dynamic").val(""); + $("#nodegroup_connectionName_dynamic").val(""); + $("#nodegroup_commonSpecId_dynamic").val(""); + $("#nodegroup_image_dynamic").val(""); + $("#nodegroup_minnodesize_dynamic").val(""); + $("#nodegroup_maxnodesize_dynamic").val(""); + $("#nodegroup_autoscaling_dynamic").val(""); + $("#nodegroup_rootdisk_dynamic").val(""); + $("#nodegroup_rootdisksize_dynamic").val(""); + $("#nodegroup_desirednodesize_dynamic").val("1"); + + // NodeGroup 폼 숨기기 + hideNodeGroupFormDynamic(); + } - // 클러스터 생성 폼 섹션을 닫기 (NodeGroup이 표시되어 있든 없든 항상 실행) - const createClusterSection = document.querySelector('#createcluster'); - if (createClusterSection && createClusterSection.classList.contains('active')) { - webconsolejs["partials/layout/navigatePages"].toggleElement(createClusterSection); - } + // Create Cluster 카드의 Deploy 버튼 표시 + $("#createcluster .card-footer").show(); - } else { - alert("failed to create cluster"); + // 2초 대기 후 PMK 목록 새로고침 (CSP에 생성 명령이 전달되는 시간 고려) + await new Promise(resolve => setTimeout(resolve, 2000)); + await refreshPmkList(); + + // 클러스터 생성 폼 섹션을 닫기 (NodeGroup이 표시되어 있든 없든 항상 실행) + const createClusterSection = document.querySelector('#createcluster'); + if (createClusterSection && createClusterSection.classList.contains('active')) { + webconsolejs["partials/layout/navigatePages"].toggleElement(createClusterSection); } + } catch (error) { console.error("failed to create cluster:", error); - alert("failed to create cluster"); + webconsolejs['common/util'].showToast('Failed to create cluster', 'error'); } } diff --git a/front/assets/js/partials/operation/manage/clustercreate.js b/front/assets/js/partials/operation/manage/clustercreate.js index 4256414d..3a8d13b5 100644 --- a/front/assets/js/partials/operation/manage/clustercreate.js +++ b/front/assets/js/partials/operation/manage/clustercreate.js @@ -8,6 +8,72 @@ export function iniClusterkCreate() { // partial init functions webconsolejs["partials/operation/manage/clusterrecommendation"].initClusterRecommendation(webconsolejs["partials/operation/manage/clustercreate"].callbackClusterRecommendation);// recommend popup에서 사용하는 table 정의. + + // Desired Node Size +/- 버튼 이벤트 리스너 설정 + setupDesiredNodeSizeButtons(); +} + +// Desired Node Size +/- 버튼 이벤트 리스너 설정 +function setupDesiredNodeSizeButtons() { + // 기존 이벤트 핸들러 제거 (중복 방지) + $(document).off('click', '#nodegroup_configuration .input-number-decrement'); + $(document).off('click', '#nodegroup_configuration .input-number-increment'); + $(document).off('change', '#node_minnodesize'); + $(document).off('change', '#node_maxnodesize'); + + // Decrement 버튼 (-) 이벤트 핸들러 + $(document).on('click', '#nodegroup_configuration .input-number-decrement', function (e) { + e.preventDefault(); + e.stopPropagation(); + + const input = $(this).siblings('.input-number'); + const currentValue = parseInt(input.val()) || 1; + const minNodeSize = parseInt($('#node_minnodesize').val()) || 1; + + // minNodeSize 이상으로 유지 + if (currentValue > minNodeSize) { + input.val(currentValue - 1); + } + }); + + // Increment 버튼 (+) 이벤트 핸들러 + $(document).on('click', '#nodegroup_configuration .input-number-increment', function (e) { + e.preventDefault(); + e.stopPropagation(); + + const input = $(this).siblings('.input-number'); + const currentValue = parseInt(input.val()) || 1; + const maxNodeSize = parseInt($('#node_maxnodesize').val()) || 5; + + // maxNodeSize 이하로 유지 + if (currentValue < maxNodeSize) { + input.val(currentValue + 1); + } + }); + + // minNodeSize 변경 시 Desired Node Size 자동 조정 + $(document).on('change', '#node_minnodesize', function () { + const minNodeSize = parseInt($(this).val()) || 1; + const desiredInput = $('#node_desirednodesize'); + const currentDesired = parseInt(desiredInput.val()) || 1; + + // Desired Node Size가 minNodeSize보다 작으면 minNodeSize로 설정 + if (currentDesired < minNodeSize) { + desiredInput.val(minNodeSize); + } + }); + + // maxNodeSize 변경 시 Desired Node Size 자동 조정 + $(document).on('change', '#node_maxnodesize', function () { + const maxNodeSize = parseInt($(this).val()) || 5; + const desiredInput = $('#node_desirednodesize'); + const currentDesired = parseInt(desiredInput.val()) || 1; + + // Desired Node Size가 maxNodeSize보다 크면 maxNodeSize로 설정 + if (currentDesired > maxNodeSize) { + desiredInput.val(maxNodeSize); + } + }); } // callback PopupData @@ -280,6 +346,7 @@ var isNodeGroup = false // mci 생성(false) / vm 추가(true) var Create_Cluster_Config_Arr = new Array(); var Create_Node_Config_Arr = new Array(); var nodeGroup_data_cnt = 0 +var currentEditingNodeGroupIndex = null; // Edit 모드 추적용 변수 // 서버 더하기버튼 클릭시 서버정보 입력area 보이기/숨기기 @@ -371,11 +438,23 @@ export async function displayNewNodeForm() { function getPlusVm(vmElementId) { var append = ""; - append = append + '
  • '; + append = append + '
  • '; append = append + "+ NodeGroup" append = append + '
  • '; return append; } + +// + NodeGroup 클릭 시 Create 모드 시작 +export function startCreateMode() { + currentEditingNodeGroupIndex = null; // Create 모드로 초기화 + console.log("Create mode: Starting new NodeGroup creation"); + + // NodeGroup Configuration 폼 표시 + var div = document.getElementById("nodegroup_configuration"); + if (div && !div.classList.contains('show')) { + webconsolejs["partials/layout/navigatePages"].toggleSubElement(div); + } +} // 서버정보 입력 area에서 'DONE'버튼 클릭시 array에 담고 form을 초기화 var totalDeployServerCount = 0; @@ -395,7 +474,60 @@ export async function createNode() { var selectedWorkspaceProject = await webconsolejs["partials/layout/navbar"].workspaceProjectInit(); var selectedNsId = selectedWorkspaceProject.nsId; var k8sClusterId = webconsolejs["pages/operation/manage/pmk"].selectedPmkObj[0].id - webconsolejs["common/api/services/pmk_api"].createNode(k8sClusterId, selectedNsId, Create_Node_Config_Arr) + + // NodeGroup 생성 요청만 보내고 결과를 기다리지 않음 (fire and forget) + webconsolejs["common/api/services/pmk_api"].createNode( + k8sClusterId, + selectedNsId, + Create_Node_Config_Arr + ); + + // 즉시 메시지 표시 + webconsolejs['common/util'].showToast('NodeGroup creation request has been sent', 'info'); + + // NodeGroup Configuration 폼 닫기 + var nodeGroupConfigDiv = document.getElementById("nodegroup_configuration"); + if (nodeGroupConfigDiv) { + webconsolejs["partials/layout/navigatePages"].toggleSubElement(nodeGroupConfigDiv); + } + + // Add Node 영역 숨기기 + var addNodeDiv = document.getElementById("addnode"); + if (addNodeDiv && addNodeDiv.classList.contains("active")) { + webconsolejs["partials/layout/navigatePages"].toggleElement(addNodeDiv); + } + + // Add NodeGroup 폼 초기화 + Create_Node_Config_Arr = new Array(); + nodeGroup_data_cnt = 0; + + // addnodegroup_list 초기화 (+ NodeGroup 버튼만 남기기) + var addNodeGroupList = document.getElementById("addnodegroup_list"); + if (addNodeGroupList) { + var plusIcon = document.getElementById("addnodegroup_plusIcon"); + addNodeGroupList.innerHTML = ''; + if (plusIcon) { + addNodeGroupList.appendChild(plusIcon); + } else { + // + NodeGroup 버튼이 없으면 다시 생성 + var li = document.createElement('li'); + li.className = 'removebullet btn btn-secondary-lt'; + li.id = 'addnodegroup_plusIcon'; + li.onclick = function() { + webconsolejs['partials/operation/manage/clustercreate'].displayNewNodeForm(); + }; + li.textContent = '+ NodeGroup'; + addNodeGroupList.appendChild(li); + } + } + + // PMK 목록 새로고침 + if (webconsolejs["pages/operation/manage/pmk"] && + typeof webconsolejs["pages/operation/manage/pmk"].refreshPmkList === 'function') { + await webconsolejs["pages/operation/manage/pmk"].refreshPmkList(); + } + + console.log("NodeGroup creation request sent and PMK list refreshed"); } // Extract region from connectionName @@ -410,6 +542,7 @@ function extractRegionFromConnection(connectionName, provider) { export async function addNewNodeGroup() { Create_Cluster_Config_Arr = new Array(); Create_Node_Config_Arr = new Array(); + currentEditingNodeGroupIndex = null; // Create 모드로 초기화 var selectedCluster = webconsolejs["pages/operation/manage/pmk"].selectedPmkObj; @@ -498,13 +631,13 @@ export async function addNewPmk() { // provider set await setProviderList(providerList) - // call getRegion API - var regionList = await webconsolejs["common/api/services/pmk_api"].getRegionList() + // call getRegion API (백그라운드, 로더 없음) + var regionList = await webconsolejs["common/api/services/pmk_api"].getRegionList({ loaderType: 'none' }) // region set await setRegionList(regionList) - // call cloudconnection - var connectionList = await webconsolejs["common/api/services/pmk_api"].getCloudConnection() + // call cloudconnection (백그라운드, 로더 없음) + var connectionList = await webconsolejs["common/api/services/pmk_api"].getCloudConnection({ loaderType: 'none' }) // cloudconnection set await setCloudConnection(connectionList) @@ -681,6 +814,9 @@ export function clusterFormDone_btn() { { id: '#node_name', message: 'NodeGroup name is required' }, { id: '#node_specid', message: 'Spec is required' }, { id: '#node_imageid', message: 'Image is required' }, + { id: '#node_minnodesize', message: 'Min Node Size is required' }, + { id: '#node_maxnodesize', message: 'Max Node Size is required' }, + { id: '#node_sshkey', message: 'SSH Key is required' }, { id: '#node_autoscaling', message: 'AutoScaling option is required' } ]; @@ -736,54 +872,80 @@ export function clusterFormDone_btn() { $("#n_autoscaling").val(onAutoScaling); $("#n_desirednodesize").val(desiredNodeSize || "1"); + // 4. NodeGroup 데이터 객체 생성 + var nodeGroupData = { + "desiredNodeSize": desiredNodeSize || "", + "imageId": imageId || "", + "maxNodeSize": maxNodeSize || "", + "minNodeSize": minNodeSize || "", + "name": nodeGroupName, + "onAutoScaling": onAutoScaling || "false", + "rootDiskSize": rootDiskSize || "", + "rootDiskType": rootDiskType || "", + "specId": specId || "", + "sshKeyId": sshKeyId || "" + }; + if (nodeGroupName) { - cluster_form["k8sNodeGroupList"] = [ - { - "desiredNodeSize": desiredNodeSize || "", - "imageId": imageId || "", - "maxNodeSize": maxNodeSize || "", - "minNodeSize": minNodeSize || "", - "name": nodeGroupName, - "onAutoScaling": onAutoScaling || "false", - "rootDiskSize": rootDiskSize || "", - "rootDiskType": rootDiskType || "", - "specId": specId || "", - "sshKeyId": sshKeyId || "" - } - ]; + cluster_form["k8sNodeGroupList"] = [nodeGroupData]; } - // 4. 배열에 저장 - var nodeGroup_name = nodeGroupName; // cluster_form.name이 아닌 nodeGroupName 사용 + + var nodeGroup_name = nodeGroupName; var nodeGroup_cnt = parseInt(desiredNodeSize) || 1; - var add_nodegroup_html = ""; + var displayNodegroupCnt = '(' + nodeGroup_cnt + ')'; - Create_Cluster_Config_Arr.push(cluster_form); - if (isNodeGroup) { - Create_Node_Config_Arr.push(cluster_form["k8sNodeGroupList"][0]); - } + // Edit 모드 vs Create 모드 구분 + if (currentEditingNodeGroupIndex !== null) { + // **Edit 모드**: 기존 NodeGroup 업데이트 + console.log("Edit mode: Updating NodeGroup at index", currentEditingNodeGroupIndex); + + // 배열의 기존 데이터 업데이트 + Create_Node_Config_Arr[currentEditingNodeGroupIndex] = nodeGroupData; + Create_Cluster_Config_Arr[currentEditingNodeGroupIndex] = cluster_form; + + // HTML 리스트 항목 업데이트 (기존 항목 찾아서 텍스트만 변경) + var targetLi = $("#nodegroup_list li").eq(currentEditingNodeGroupIndex + 1); // +1은 plusIcon 때문 + if (targetLi.length > 0) { + targetLi.text(nodeGroup_name + displayNodegroupCnt); + // onclick 이벤트 다시 설정 + targetLi.attr('onclick', "webconsolejs['partials/operation/manage/clustercreate'].view_ngForm('" + currentEditingNodeGroupIndex + "')"); + } + + // Edit 모드 종료 + currentEditingNodeGroupIndex = null; + + } else { + // **Create 모드**: 새 NodeGroup 추가 + console.log("Create mode: Adding new NodeGroup"); + + // 배열에 저장 + Create_Cluster_Config_Arr.push(cluster_form); + if (isNodeGroup) { + Create_Node_Config_Arr.push(nodeGroupData); + } - // 5. HTML 생성 (NodeGroup 리스트 항목) - var displayNodegroupCnt = '(' + nodeGroup_cnt + ')'; - add_nodegroup_html += '
  • ' - + nodeGroup_name + displayNodegroupCnt - + '
  • '; + // HTML 생성 (NodeGroup 리스트 항목) + var add_nodegroup_html = '
  • ' + + nodeGroup_name + displayNodegroupCnt + + '
  • '; - // 6. 폼 토글 (먼저 실행) - var div = document.getElementById("nodegroup_configuration"); - webconsolejs["partials/layout/navigatePages"].toggleSubElement(div); + // plusIcon 제거 및 리스트 업데이트 + var ngEleId = "nodegroup"; + if (isNodeGroup) { + ngEleId = "addnodegroup"; + } + + $("#" + ngEleId + "_plusIcon").remove(); + $("#" + ngEleId + "_list").append(add_nodegroup_html); + $("#" + ngEleId + "_list").prepend(getPlusVm(ngEleId)); - // 7. plusIcon 제거 및 리스트 업데이트 - var ngEleId = "nodegroup"; - if (isNodeGroup) { - ngEleId = "addnodegroup"; + // 카운터 증가 + nodeGroup_data_cnt++; } - - $("#" + ngEleId + "_plusIcon").remove(); - $("#" + ngEleId + "_list").append(add_nodegroup_html); - $("#" + ngEleId + "_list").prepend(getPlusVm(ngEleId)); - // 8. 카운터 증가 - nodeGroup_data_cnt++; + // 폼 토글 + var div = document.getElementById("nodegroup_configuration"); + webconsolejs["partials/layout/navigatePages"].toggleSubElement(div); // 9. 폼 초기화 $("#cluster_form").each(function () { @@ -884,8 +1046,48 @@ export function addNodeFormDone_btn() { } export function view_ngForm(cnt){ + // NodeGroup Configuration 폼 표시 var div = document.getElementById("nodegroup_configuration"); - webconsolejs["partials/layout/navigatePages"].toggleElement(div) + webconsolejs["partials/layout/navigatePages"].toggleElement(div); + + // 배열에서 해당 NodeGroup 데이터 가져오기 + if (cnt !== undefined && Create_Node_Config_Arr[cnt]) { + // Edit 모드 활성화 + currentEditingNodeGroupIndex = cnt; + + var nodeGroupData = Create_Node_Config_Arr[cnt]; + + // Form 필드에 기존 데이터 채우기 + $("#node_name").val(nodeGroupData.name || ""); + $("#node_specid").val(nodeGroupData.specId || ""); + $("#node_commonSpecId").val(nodeGroupData.specId || ""); + $("#node_imageid").val(nodeGroupData.imageId || ""); + $("#node_minnodesize").val(nodeGroupData.minNodeSize || ""); + $("#node_maxnodesize").val(nodeGroupData.maxNodeSize || ""); + $("#node_sshkey").val(nodeGroupData.sshKeyId || ""); + $("#node_rootdisk").val(nodeGroupData.rootDiskType || ""); + $("#node_rootdisksize").val(nodeGroupData.rootDiskSize || ""); + $("#node_autoscaling").val(nodeGroupData.onAutoScaling || "false"); + $("#node_desirednodesize").val(nodeGroupData.desiredNodeSize || "1"); + + // Hidden 필드에도 설정 + $("#n_name").val(nodeGroupData.name || ""); + $("#n_specid").val(nodeGroupData.specId || ""); + $("#n_imageid").val(nodeGroupData.imageId || ""); + $("#n_minnodesize").val(nodeGroupData.minNodeSize || ""); + $("#n_maxnodesize").val(nodeGroupData.maxNodeSize || ""); + $("#n_sshkey").val(nodeGroupData.sshKeyId || ""); + $("#n_rootdisk").val(nodeGroupData.rootDiskType || ""); + $("#n_rootdisksize").val(nodeGroupData.rootDiskSize || ""); + $("#n_autoscaling").val(nodeGroupData.onAutoScaling || "false"); + $("#n_desirednodesize").val(nodeGroupData.desiredNodeSize || "1"); + + console.log("Edit mode: Loaded NodeGroup data at index", cnt, ":", nodeGroupData); + } else { + // Create 모드 (+ NodeGroup 클릭 시) + currentEditingNodeGroupIndex = null; + console.log("Create mode: New NodeGroup"); + } } // PMK용 Server Recommendation 콜백 함수 (Runtime nodegroup_configuration 폼용) diff --git a/front/assets/js/partials/operation/manage/pmk_imagerecommendation.js b/front/assets/js/partials/operation/manage/pmk_imagerecommendation.js index 752c57e9..eeaf1b15 100644 --- a/front/assets/js/partials/operation/manage/pmk_imagerecommendation.js +++ b/front/assets/js/partials/operation/manage/pmk_imagerecommendation.js @@ -209,7 +209,7 @@ export async function getRecommendImageInfoPmk() { providerName: provider, // 개별 전달 regionName: region, // 개별 전달 matchedSpecId: specId, // 유지 - isKubernetesImage: true, // PMK 필수 + // isKubernetesImage: true, // PMK 필수 maxResults: 100 }; diff --git a/front/assets/js/partials/operation/manage/pmk_serverrecommendation.js b/front/assets/js/partials/operation/manage/pmk_serverrecommendation.js index cc472767..6c802a48 100644 --- a/front/assets/js/partials/operation/manage/pmk_serverrecommendation.js +++ b/front/assets/js/partials/operation/manage/pmk_serverrecommendation.js @@ -20,24 +20,8 @@ export function initServerRecommendationPmk(callbackfunction) { // PMK용 서버 추천 모달 이벤트 설정 function setupServerModalEventsPmk() { - // MCI용과 동일하게 단순한 이벤트 리스너만 등록 - - // Bootstrap 5 방식 - if (typeof bootstrap !== 'undefined' && bootstrap.Modal) { - var pmkModal = document.getElementById('spec-search-pmk'); - if (pmkModal) { - pmkModal.addEventListener('shown.bs.modal', function() { - // 모달이 열렸을 때의 처리 - }); - } - } - - // jQuery 방식 - if (typeof $ !== 'undefined' && $.fn.modal) { - $("#spec-search-pmk").on('shown.bs.modal', function() { - // 모달이 열렸을 때의 처리 - }); - } + // Provider 옵션은 HTML partial component로 이미 렌더링됨 + // 추가 초기화가 필요한 경우 여기에 작성 } function initRecommendSpecTablePmk() { diff --git a/front/templates/pages/operations/manage/workloads/pmkworkloads.html b/front/templates/pages/operations/manage/workloads/pmkworkloads.html index d8801635..4e867f95 100644 --- a/front/templates/pages/operations/manage/workloads/pmkworkloads.html +++ b/front/templates/pages/operations/manage/workloads/pmkworkloads.html @@ -380,7 +380,7 @@

    Create Cluster

    class="form-select" id="cluster_provider_dynamic" > - + <%= partial("partials/common/provider_select.html") %>
    @@ -1279,8 +1279,9 @@

    Drop files here or click to upload.

    -<%= javascriptTag("pages/operation/manage/pmk.js") %> <%= -javascriptTag("common/api/services/pmk_api.js") %> <%= +<%= javascriptTag("common/utils/listRefreshPattern.js") %> +<%= javascriptTag("pages/operation/manage/pmk.js") %> +<%= javascriptTag("common/api/services/pmk_api.js") %> <%= javascriptTag("common/api/services/mci_api.js") %> <%= javascriptTag("common/api/services/vmimage_api.js") %> <%= javascriptTag("partials/operation/manage/serverrecommendation.js") %> <%= diff --git a/front/templates/partials/common/_provider_select.html b/front/templates/partials/common/_provider_select.html new file mode 100644 index 00000000..f3a2944b --- /dev/null +++ b/front/templates/partials/common/_provider_select.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/front/templates/partials/operation/manage/_clusterinfo.html b/front/templates/partials/operation/manage/_clusterinfo.html index 86207141..d1a19dda 100644 --- a/front/templates/partials/operation/manage/_clusterinfo.html +++ b/front/templates/partials/operation/manage/_clusterinfo.html @@ -27,6 +27,14 @@

    Name
    +
    +
    CspName
    +
    +
    +
    +
    CspId
    +
    +
    Version
    diff --git a/front/templates/partials/operation/manage/_nodegroupinfo.html b/front/templates/partials/operation/manage/_nodegroupinfo.html index b662b2e8..4f662553 100644 --- a/front/templates/partials/operation/manage/_nodegroupinfo.html +++ b/front/templates/partials/operation/manage/_nodegroupinfo.html @@ -14,6 +14,20 @@

    Name

    +
    +
    Id
    +
    +
    +
    +
    Status
    +
    +
    + + + +
    +
    +
    Image
    @@ -24,16 +38,16 @@

    +
    +
    Key Pair
    +
    +
    -
    -
    Key Pair
    -
    -
    Desired Node Size
    @@ -47,12 +61,6 @@

    id="ng_info_nodesize" >

    -
    -
    -
    -
    -
    -
    Auto Scaling
    id="ng_info_autoscaling" >
    +
    +
    +
    + +
    +
    +
    +
    Root Disk Type
    diff --git a/front/templates/partials/operation/manage/_pmk_serverrecommendation.html b/front/templates/partials/operation/manage/_pmk_serverrecommendation.html index 2c7378a7..f70092c1 100644 --- a/front/templates/partials/operation/manage/_pmk_serverrecommendation.html +++ b/front/templates/partials/operation/manage/_pmk_serverrecommendation.html @@ -180,15 +180,7 @@

    name="spec-provider-filter-pmk" onchange="webconsolejs['partials/operation/manage/pmk_serverrecommendation'].filterByProviderPmk(this.value)" > - - - - - - - - - + <%= partial("partials/common/provider_select.html") %>

    From a9718087f45a0ba49c09530c5047c3c249954ee6 Mon Sep 17 00:00:00 2001 From: MZC-CSC <78469943+MZC-CSC@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:33:40 +0900 Subject: [PATCH 2/3] Delete doc/frontend/HybridLoaderPattern.md --- doc/frontend/HybridLoaderPattern.md | 473 ---------------------------- 1 file changed, 473 deletions(-) delete mode 100644 doc/frontend/HybridLoaderPattern.md diff --git a/doc/frontend/HybridLoaderPattern.md b/doc/frontend/HybridLoaderPattern.md deleted file mode 100644 index 347c0505..00000000 --- a/doc/frontend/HybridLoaderPattern.md +++ /dev/null @@ -1,473 +0,0 @@ -# Hybrid Loader Pattern 가이드 - -## 개요 - -Hybrid Loader Pattern은 여러 API를 동시에 호출할 때 각각의 진행 상황을 독립적으로 표시할 수 있는 로더 시스템입니다. - -### 문제점 - -기존 시스템에서는 여러 API를 동시에 호출할 때 먼저 응답을 받는 API가 전체 페이지 로더를 닫아버려, 나머지 API가 아직 진행 중임에도 프로그레스 표시가 사라지는 문제가 있었습니다. - -### 해결책 - -세 가지 로더 타입을 제공하여 상황에 맞게 선택할 수 있습니다: -- **Page Loader**: 전체 페이지를 블로킹하는 중요한 작업 -- **Toast Loader**: 개별 API마다 독립적인 프로그레스 표시 -- **No Loader**: 백그라운드 작업, 사용자 인지 불필요 - -## Loader Type 선택 기준 - -### 🔵 PAGE LOADER (전체 페이지 로더) - -**사용 시나리오**: -- 사용자 액션으로 시작된 중요한 작업 -- 페이지 전체가 블로킹되어야 하는 작업 -- 작업 완료까지 다른 조작을 막아야 하는 경우 -- **동기적으로 결과를 기다려야 하는 조회 작업** ✨ - -**예시**: -- 생성 (Create Cluster, Create NodeGroup) -- 삭제 (Delete Cluster, Delete NodeGroup) -- 수정 (Update Configuration) -- 실행 (Start, Stop, Reboot) -- **목록 조회 (GetAllK8sCluster)** ✨ -- **상세 조회 (Getk8scluster)** ✨ -- **Refresh 버튼 클릭** ✨ - -```javascript -{ - loaderType: 'page' -} -``` - -### 🟢 TOAST LOADER (개별 프로그레스 toast) - -**사용 시나리오**: -- 백그라운드 데이터 로딩 -- **비동기적으로 독립적으로 로딩되는 부가 데이터** ✨ -- 일부 데이터 로딩이 실패해도 페이지 사용이 가능한 경우 -- 사용자가 기다리지 않아도 되는 데이터 - -**예시**: -- 모니터링 데이터 (실시간 통계) -- 대시보드 위젯 -- 백그라운드 통계 업데이트 -- 선택적 부가 정보 - -```javascript -{ - loaderType: 'toast', - progressLabel: 'Loading Monitoring Data...', - successMessage: null // 성공 메시지 표시 안 함 -} -``` - -### ⚪ NO LOADER - -**사용 시나리오**: -- 폴링(주기적 업데이트) -- 사용자가 인지할 필요 없는 백그라운드 작업 -- 실시간 상태 업데이트 -- Heartbeat, Health Check - -```javascript -{ - loaderType: 'none' -} -``` - -## 페이지별 구현 패턴 - -### 1. Loader Config 정의 - -각 페이지 상단에 `[PAGE]_LOADER_CONFIG` 객체를 정의합니다: - -```javascript -/** - * =================================================================== - * PMK WORKLOADS PAGE - LOADER STRATEGY - * =================================================================== - * 📄 Page Loader: Create, Delete, Update operations - * 🔔 Toast Loader: Data fetching (list, details, monitoring) - * ⚪ No Loader: Background status updates - * =================================================================== - */ - -const PMK_LOADER_CONFIG = { - // 생성/삭제/수정 작업 - PAGE LOADER - create: { - cluster: { loaderType: 'page' }, - nodeGroup: { loaderType: 'page' } - }, - - delete: { - cluster: { loaderType: 'page' }, - nodeGroup: { loaderType: 'page' } - }, - - update: { - cluster: { loaderType: 'page' }, - nodeGroup: { loaderType: 'page' } - }, - - // 조회 작업 - PAGE LOADER (동기 조회) - fetch: { - // 동기 조회 - 사용자가 결과를 기다려야 하는 중요한 데이터 - clusterList: { - loaderType: 'page' // GetAllK8sCluster - }, - - clusterDetail: { - loaderType: 'page' // Getk8scluster - }, - - // 비동기 조회 - 백그라운드로 독립적으로 로딩되는 부가 데이터 - monitoring: { - loaderType: 'toast', - progressLabel: 'Loading Monitoring Data...', - successMessage: null - } - }, - - // 백그라운드 작업 - NO LOADER - background: { - statusUpdate: { loaderType: 'none' }, - heartbeat: { loaderType: 'none' } - } -}; -``` - -### 2. API Helper 생성 - -Config를 사용하는 Helper 객체를 만듭니다: - -```javascript -const PmkApiHelper = { - // 조회 작업 - async getClusterList(nsId) { - return await webconsolejs["common/api/services/pmk_api"].getClusterList( - nsId, - PMK_LOADER_CONFIG.fetch.clusterList - ); - }, - - async getClusterDetail(nsId, clusterId) { - return await webconsolejs["common/api/services/pmk_api"].getCluster( - nsId, - clusterId, - PMK_LOADER_CONFIG.fetch.clusterDetail - ); - }, - - async getNodeGroups(nsId, clusterId) { - return await webconsolejs["common/api/services/pmk_api"].getNodeGroups( - nsId, - clusterId, - PMK_LOADER_CONFIG.fetch.nodeGroupList - ); - }, - - // 생성/삭제 작업 - async createCluster(nsId, data) { - return await webconsolejs["common/api/services/pmk_api"].createCluster( - nsId, - data, - PMK_LOADER_CONFIG.create.cluster - ); - }, - - async deleteCluster(nsId, clusterId) { - return await webconsolejs["common/api/services/pmk_api"].deleteCluster( - nsId, - clusterId, - PMK_LOADER_CONFIG.delete.cluster - ); - }, - - // 여러 데이터 동시 로딩 - async loadMultipleData(nsId, clusterId) { - return await Promise.all([ - this.getClusterDetail(nsId, clusterId), - this.getNodeGroups(nsId, clusterId), - webconsolejs["common/api/services/pmk_api"].getMonitoring( - nsId, - clusterId, - PMK_LOADER_CONFIG.fetch.monitoring - ) - ]); - } -}; -``` - -### 3. 기존 함수를 Helper 사용으로 변경 - -```javascript -// ❌ Before - 직접 API 호출 -export async function refreshPmkList() { - if (selectedWorkspaceProject.projectId != "") { - var respPmkList = await webconsolejs["common/api/services/pmk_api"] - .getClusterList(selectedNsId); - getPmkListCallbackSuccess(selectedProjectId, respPmkList); - } -} - -// ✅ After - Helper 사용 -export async function refreshPmkList() { - if (selectedWorkspaceProject.projectId != "") { - const config = { - fetchListData: async () => { - return await PmkApiHelper.getClusterList(selectedNsId); - }, - updateListCallback: (respPmkList) => { - getPmkListCallbackSuccess(selectedProjectId, respPmkList); - }, - // ... 나머지 config - }; - - await webconsolejs['common/utils/listRefreshPattern'].execute(config); - } -} -``` - -## 사용 예시 - -### 단일 API 호출 (Page Loader) - -```javascript -export async function deletePmk() { - // ... validation ... - - // Page Loader가 자동으로 표시됨 - const result = await PmkApiHelper.deleteCluster( - selectedNsId, - currentPmkId - ); - - if (result && result.status === 200) { - alert('Cluster deleted successfully'); - await refreshPmkList(); - } -} -``` - -### 여러 API 동시 호출 (Toast Loader) - -```javascript -export async function getSelectedPmkData() { - if (currentPmkId) { - try { - // 3개의 Toast가 동시에 표시됨 - // 각 API가 완료되면 해당 Toast만 사라짐 - const [clusterDetail, nodeGroups, monitoring] = - await PmkApiHelper.loadMultipleData(selectedNsId, currentPmkId); - - if (clusterDetail && clusterDetail.status === 200) { - setPmkInfoData(clusterDetail.data); - } - - if (nodeGroups && nodeGroups.status === 200) { - displayNodeGroupList(nodeGroups.data); - } - - if (monitoring && monitoring.status === 200) { - displayMonitoringData(monitoring.data); - } - } catch (error) { - console.error('Error loading PMK data:', error); - } - } -} -``` - -### 목록 새로고침 (Toast Loader) - -```javascript -export async function refreshPmkList() { - if (selectedWorkspaceProject.projectId != "") { - const config = { - getSelectionId: () => currentPmkId, - detailElementIds: ['cluster_info'], - detailElementsToEmpty: ['pmk_nodegroup_info_box', 'pmk_node_info_box'], - formsToClose: ['nodegroup_configuration'], - - fetchListData: async () => { - // "Loading PMK Clusters..." toast 표시 - return await PmkApiHelper.getClusterList(selectedNsId); - }, - - updateListCallback: (respPmkList) => { - getPmkListCallbackSuccess(selectedProjectId, respPmkList); - }, - - // ... 나머지 config - }; - - await webconsolejs['common/utils/listRefreshPattern'].execute(config); - } -} -``` - -## UI 표시 예시 - -### Page Loader -전체 화면을 덮는 로더: -``` -┌────────────────────────────────────┐ -│ │ -│ 🔄 Preparing Data │ -│ │ -└────────────────────────────────────┘ -``` - -### Toast Loader -화면 우측 상단에 쌓이는 독립적인 toast: -``` - ┌─────────────────────────────┐ - │ 🔄 Loading PMK Clusters... │ - └─────────────────────────────┘ - ┌─────────────────────────────┐ - │ 🔄 Loading Node Groups... │ - └─────────────────────────────┘ - ┌─────────────────────────────┐ - │ 🔄 Loading Monitoring... │ - └─────────────────────────────┘ -``` - -## 다른 페이지 적용 가이드 - -### VM Workloads 적용 예시 - -```javascript -// vm.js - -const VM_LOADER_CONFIG = { - create: { - vm: { loaderType: 'page' } - }, - - delete: { - vm: { loaderType: 'page' } - }, - - fetch: { - vmList: { - loaderType: 'toast', - progressLabel: 'Loading VMs...' - }, - vmDetail: { - loaderType: 'toast', - progressLabel: 'Loading VM Details...' - } - }, - - action: { - start: { loaderType: 'page' }, - stop: { loaderType: 'page' }, - reboot: { loaderType: 'page' } - } -}; - -const VmApiHelper = { - async getVmList(nsId) { - return await webconsolejs["common/api/services/vm_api"].getVmList( - nsId, - VM_LOADER_CONFIG.fetch.vmList - ); - }, - - async startVm(nsId, vmId) { - return await webconsolejs["common/api/services/vm_api"].startVm( - nsId, - vmId, - VM_LOADER_CONFIG.action.start - ); - } -}; -``` - -## 구현 체크리스트 - -새로운 페이지에 패턴을 적용할 때: - -- [ ] `[PAGE]_LOADER_CONFIG` 객체 정의 -- [ ] `[Page]ApiHelper` 객체 생성 -- [ ] 기존 API 호출을 Helper로 변경 -- [ ] Page Loader가 필요한 작업 확인 -- [ ] Toast Loader가 필요한 작업 확인 -- [ ] 여러 API 동시 호출 시나리오 확인 -- [ ] 테스트 (단일 API, 복수 API) - -## 주의사항 - -1. **성공 메시지**: 대부분의 조회 작업은 `successMessage: null`로 설정하여 성공 메시지를 표시하지 않습니다. - -2. **에러 처리**: Toast loader는 에러 발생 시 자동으로 에러 toast를 표시하지 않습니다. 필요시 별도 처리가 필요합니다. - -3. **동시 호출**: `Promise.all`을 사용하여 여러 API를 동시에 호출할 때 각 Toast가 독립적으로 표시됩니다. - -4. **기본값**: `loaderType`을 지정하지 않으면 기본적으로 `page` loader가 사용됩니다. - -## 기술적 구현 - -### http.js의 로직 - -```javascript -export async function commonAPIPost(url, data, attempt, options = {}) { - const loaderType = options.loaderType || 'page'; - let toastId = null; - - try { - // Loader 시작 - if (loaderType === 'toast') { - toastId = showAPIProgressToast(url, options.progressLabel); - } else if (loaderType === 'page') { - activePageLoader(); - } - - // API 호출 - const response = await axios.post(url, data); - - return response; - } catch (error) { - // 에러 처리 - throw error; - } finally { - // Loader 종료 (항상 실행) - if (loaderType === 'toast' && toastId) { - hideAPIProgressToast(toastId, success, options.successMessage); - } else if (loaderType === 'page') { - deactivePageLoader(); - } - } -} -``` - -## 트러블슈팅 - -### Toast가 표시되지 않음 - -**원인**: Toast 시스템이 초기화되지 않았거나 `webconsolejs['common/utils/toast']`가 로드되지 않음 - -**해결**: HTML에서 `toast.js`가 `http.js` 이전에 로드되는지 확인 - -### Page Loader가 닫히지 않음 - -**원인**: API 호출 중 에러가 발생했지만 `finally` 블록이 실행되지 않음 - -**해결**: `try-finally` 구조 확인 및 `deactivePageLoader()` 호출 확인 - -### 여러 Toast가 겹쳐 보임 - -**정상 동작**: Toast는 화면 우측 상단에 쌓이도록 설계되었습니다. 각 Toast는 독립적으로 사라집니다. - -## 관련 파일 - -- **유틸리티**: `front/assets/js/common/api/http.js` -- **Toast 시스템**: `front/assets/js/common/utils/toast.js` -- **적용 예시**: `front/assets/js/pages/operation/manage/pmk.js` -- **문서**: `doc/frontend/HybridLoaderPattern.md` (현재 문서) - -## 버전 히스토리 - -- **v1.0.0** (2024): 초기 구현 및 PMK 화면 적용 - From a1529edb5e859a5f57807d88dc4ff8e427e7fcfb Mon Sep 17 00:00:00 2001 From: MZC-CSC <78469943+MZC-CSC@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:33:59 +0900 Subject: [PATCH 3/3] Delete doc/frontend/ListRefreshPattern.md --- doc/frontend/ListRefreshPattern.md | 436 ----------------------------- 1 file changed, 436 deletions(-) delete mode 100644 doc/frontend/ListRefreshPattern.md diff --git a/doc/frontend/ListRefreshPattern.md b/doc/frontend/ListRefreshPattern.md deleted file mode 100644 index 92403b7e..00000000 --- a/doc/frontend/ListRefreshPattern.md +++ /dev/null @@ -1,436 +0,0 @@ -# List Refresh Pattern 가이드 - -## 개요 - -List Refresh Pattern은 목록 화면의 일관된 refresh 동작을 제공하는 공통 패턴입니다. 이 패턴을 사용하면 다음과 같은 이점을 얻을 수 있습니다: - -- **일관성**: 모든 목록 화면에서 동일한 refresh 동작 -- **재사용성**: 설정 객체만 변경하면 어디서든 사용 가능 -- **유지보수**: 패턴 수정 시 한 곳만 변경 -- **확장성**: 새로운 화면 추가 시 설정만 정의 -- **에러 처리**: 공통 에러 처리 로직 - -## 주요 기능 - -1. **상태 저장 및 복원**: 현재 선택된 항목을 자동으로 저장하고 refresh 후 복원 -2. **UI 초기화**: 상세 영역 숨기기, 내용 비우기, 폼 닫기를 자동으로 처리 -3. **에러 처리**: 통합된 에러 처리 및 사용자 피드백 -4. **유연한 설정**: 각 화면의 특성에 맞게 설정 가능 - -## 사용 방법 - -### 1. 기본 사용법 - -```javascript -// 1단계: Config 객체 정의 -const config = { - // 필수: 현재 선택된 ID 반환 - getSelectionId: () => currentItemId, - - // 선택: 숨길 element ID 배열 - detailElementIds: ['detail_info'], - - // 선택: 비울 element ID 배열 - detailElementsToEmpty: ['detail_content', 'sub_info'], - - // 선택: 닫을 폼 ID 배열 - formsToClose: ['edit_form'], - - // 필수: 데이터 조회 함수 - fetchListData: async () => { - return await api.getList(nsId); - }, - - // 필수: 목록 업데이트 함수 - updateListCallback: (data) => { - updateTable(data); - }, - - // 선택: Row 조회 함수 - getRowById: (id) => { - try { return table.getRow(id); } - catch (e) { return null; } - }, - - // 선택: Row 선택 함수 - selectRow: (id) => { - table.selectRow(id); - }, - - // 선택: 상세 정보 표시 함수 - showDetailData: async () => { - await loadDetailData(); - }, - - // 선택: 선택 상태 초기화 함수 - clearSelectionState: () => { - currentItemId = ''; - selectedData = {}; - }, - - // 선택: 에러 메시지 - errorMessage: 'Failed to refresh list.' -}; - -// 2단계: Pattern 실행 -await webconsolejs['common/utils/listRefreshPattern'].execute(config); -``` - -### 2. Refresh 함수에 통합 - -```javascript -export async function refreshMyList() { - if (selectedWorkspaceProject.projectId != "") { - const config = getRefreshConfig(); - await webconsolejs['common/utils/listRefreshPattern'].execute(config); - } -} -``` - -## Config 옵션 상세 - -### 필수 옵션 - -#### `fetchListData: async () => Promise` -목록 데이터를 가져오는 비동기 함수입니다. - -```javascript -fetchListData: async () => { - return await webconsolejs["common/api/services/my_api"].getList(selectedNsId); -} -``` - -#### `updateListCallback: (data) => void` -가져온 데이터로 목록을 업데이트하는 함수입니다. - -```javascript -updateListCallback: (respList) => { - getListCallbackSuccess(selectedProjectId, respList); -} -``` - -### 선택 옵션 - -#### `getSelectionId: () => string` -현재 선택된 항목의 ID를 반환하는 함수입니다. 이 함수를 제공하면 refresh 후 선택 상태가 자동으로 복원됩니다. - -```javascript -getSelectionId: () => currentPmkId -``` - -#### `detailElementIds: string[]` -숨겨야 할 상세 영역 element ID 배열입니다. - -```javascript -detailElementIds: ['cluster_info', 'node_info'] -``` - -#### `detailElementsToEmpty: string[]` -내용을 비워야 할 element ID 배열입니다. - -```javascript -detailElementsToEmpty: ['pmk_nodegroup_info_box', 'pmk_node_info_box'] -``` - -#### `formsToClose: string[]` -닫아야 할 폼 element ID 배열입니다. `active` 클래스를 확인하여 열려있는 폼만 닫습니다. - -```javascript -formsToClose: ['nodegroup_configuration', 'cluster_edit_form'] -``` - -#### `getRowById: (id) => object|null` -ID로 테이블 row 객체를 가져오는 함수입니다. 선택 상태 복원에 사용됩니다. - -```javascript -getRowById: (id) => { - try { - return pmkListTable.getRow(id); - } catch (e) { - return null; - } -} -``` - -#### `selectRow: (id) => void` -테이블에서 특정 row를 선택하는 함수입니다. - -```javascript -selectRow: (id) => { - toggleRowSelection(id); -} -``` - -#### `showDetailData: async () => Promise` -선택된 항목의 상세 정보를 표시하는 비동기 함수입니다. - -```javascript -showDetailData: async () => { - await getSelectedPmkData(); -} -``` - -#### `clearSelectionState: () => void` -선택 상태를 초기화하는 함수입니다. 선택된 항목이 삭제된 경우 호출됩니다. - -```javascript -clearSelectionState: () => { - currentPmkId = ''; - currentNodeGroupName = ''; - currentProvider = ''; - selectedClusterData = {}; -} -``` - -#### `errorMessage: string` -에러 발생 시 표시할 메시지입니다. 지정하지 않으면 기본 메시지가 표시됩니다. - -```javascript -errorMessage: 'Failed to refresh PMK list. Please try again.' -``` - -## 실제 적용 예시 (PMK) - -### 전체 코드 - -```javascript -/** - * PMK 목록 새로고침 - * Refresh PMK list - */ -export async function refreshPmkList() { - if (selectedWorkspaceProject.projectId != "") { - var selectedProjectId = selectedWorkspaceProject.projectId; - var selectedNsId = selectedWorkspaceProject.nsId; - - // List Refresh Pattern 설정 - const config = { - getSelectionId: () => currentPmkId, - detailElementIds: ['cluster_info'], - detailElementsToEmpty: ['pmk_nodegroup_info_box', 'pmk_node_info_box'], - formsToClose: ['nodegroup_configuration'], - - fetchListData: async () => { - return await webconsolejs["common/api/services/pmk_api"] - .getClusterList(selectedNsId); - }, - - updateListCallback: (respPmkList) => { - getPmkListCallbackSuccess(selectedProjectId, respPmkList); - }, - - getRowById: (id) => { - try { return pmkListTable.getRow(id); } - catch (e) { return null; } - }, - - selectRow: (id) => { - toggleRowSelection(id); - }, - - showDetailData: async () => { - await getSelectedPmkData(); - }, - - clearSelectionState: () => { - currentPmkId = ''; - currentNodeGroupName = ''; - currentProvider = ''; - selectedClusterData = {}; - }, - - errorMessage: 'Failed to refresh PMK list. Please try again.' - }; - - await webconsolejs['common/utils/listRefreshPattern'].execute(config); - } -} -``` - -### 적용 시나리오 - -PMK 화면에서 다음 모든 시나리오에서 일관되게 동작합니다: - -1. **화면 최초 로드 시**: `initPmk()` → `refreshPmkList()` -2. **Refresh 아이콘 클릭**: 직접 `refreshPmkList()` 호출 -3. **NodeGroup 추가 후**: `createNode()` → `refreshPmkList()` -4. **NodeGroup 삭제 후**: `deleteNodeGroup()` → `refreshPmkList()` -5. **Cluster 삭제 후**: `deletePmk()` → `refreshPmkList()` - -## 다른 화면 적용 가이드 - -### VM Workloads 화면 적용 예시 - -```javascript -export async function refreshVmList() { - if (selectedWorkspaceProject.projectId != "") { - var selectedProjectId = selectedWorkspaceProject.projectId; - var selectedNsId = selectedWorkspaceProject.nsId; - - const config = { - getSelectionId: () => currentVmId, - detailElementIds: ['vm_detail_info'], - detailElementsToEmpty: ['vm_monitoring_box', 'vm_ssh_terminal_box'], - formsToClose: ['vm_configuration_form'], - - fetchListData: async () => { - return await webconsolejs["common/api/services/vm_api"] - .getVmList(selectedNsId); - }, - - updateListCallback: (respVmList) => { - getVmListCallbackSuccess(selectedProjectId, respVmList); - }, - - getRowById: (id) => { - try { return vmListTable.getRow(id); } - catch (e) { return null; } - }, - - selectRow: (id) => { - toggleVmRowSelection(id); - }, - - showDetailData: async () => { - await getSelectedVmData(); - }, - - clearSelectionState: () => { - currentVmId = ''; - selectedVmData = {}; - }, - - errorMessage: 'Failed to refresh VM list. Please try again.' - }; - - await webconsolejs['common/utils/listRefreshPattern'].execute(config); - } -} -``` - -### 적용 체크리스트 - -새로운 화면에 패턴을 적용할 때 다음 체크리스트를 따르세요: - -- [ ] 현재 refresh 로직 분석 -- [ ] 전역 변수 식별 (선택 상태, 상세 데이터 등) -- [ ] UI 요소 식별 (상세 영역, 폼 등) -- [ ] Config 객체 작성 -- [ ] 기존 refresh 함수를 패턴 사용 방식으로 변경 -- [ ] 모든 refresh 호출 지점에서 테스트 -- [ ] JSDoc 주석 추가 - -## 실행 흐름 - -``` -사용자 액션 (삭제/추가/새로고침) - ↓ -refreshList() 호출 - ↓ -ListRefreshPattern.execute(config) - ↓ -1. 현재 선택 ID 저장 (getSelectionId) - ↓ -2. UI 초기화 - - 상세 영역 숨기기 (detailElementIds) - - 내용 비우기 (detailElementsToEmpty) - - 폼 닫기 (formsToClose) - ↓ -3. 데이터 조회 (fetchListData) - ↓ -4. 목록 업데이트 (updateListCallback) - ↓ -5. 선택 상태 복원 - ├─ 항목 존재 → selectRow + showDetailData - └─ 항목 삭제 → clearSelectionState - ↓ -완료 -``` - -## 트러블슈팅 - -### 문제: Pattern이 undefined 에러 - -**원인**: Webpack이 유틸리티를 번들링하지 못했거나 로드 순서 문제 - -**해결**: -1. 브라우저 콘솔에서 확인: - ```javascript - console.log(webconsolejs['common/utils/listRefreshPattern']); - ``` -2. 유틸리티 파일이 올바르게 export되었는지 확인 -3. 페이지 새로고침 (Ctrl+F5) - -### 문제: 선택 상태가 복원되지 않음 - -**원인**: `getRowById`가 제대로 작동하지 않거나 ID가 변경됨 - -**해결**: -1. `getRowById` 함수가 올바르게 구현되었는지 확인 -2. try-catch로 에러를 잡고 null 반환하는지 확인 -3. ID가 refresh 전후에 동일한지 확인 - -### 문제: 상세 영역이 숨겨지지 않음 - -**원인**: Element ID가 잘못되었거나 jQuery 선택자 문제 - -**해결**: -1. Element ID가 올바른지 확인 (대소문자 구분) -2. 브라우저 개발자 도구에서 Element 확인 -3. `$('#element_id').length`로 존재 여부 확인 - -### 문제: 폼이 닫히지 않음 - -**원인**: 폼이 `active` 클래스를 사용하지 않거나 다른 토글 방식 사용 - -**해결**: -1. 폼의 실제 토글 방식 확인 -2. 필요시 `formsToClose` 대신 `resetUI`에서 커스텀 로직 추가 -3. 또는 config에 커스텀 폼 닫기 함수 추가 - -## 향후 적용 대상 화면 - -우선순위 순서로 다음 화면에 패턴을 적용할 예정입니다: - -### 1순위: VM Workloads -- 파일: `front/assets/js/pages/operation/manage/vm.js` -- 예상 작업: 1-2시간 -- 복잡도: 중간 - -### 2순위: MCI Workloads -- 파일: `front/assets/js/pages/operation/manage/mci.js` -- 예상 작업: 1-2시간 -- 복잡도: 중간 - -### 3순위: NLB Workloads -- 파일: `front/assets/js/pages/operation/manage/nlb.js` -- 예상 작업: 1시간 -- 복잡도: 낮음 - -### 4순위: Disk Management -- 파일: `front/assets/js/pages/operation/manage/disk.js` -- 예상 작업: 1시간 -- 복잡도: 낮음 - -### 5순위: Security Group Management -- 파일: `front/assets/js/pages/operation/manage/securityGroup.js` -- 예상 작업: 1-2시간 -- 복잡도: 중간 - -## 관련 파일 - -- **유틸리티**: `front/assets/js/common/utils/listRefreshPattern.js` -- **적용 예시**: `front/assets/js/pages/operation/manage/pmk.js` -- **문서**: `doc/frontend/ListRefreshPattern.md` (현재 문서) - -## 참고 사항 - -- 모든 함수는 JSDoc으로 한글/영문 병기 주석 작성 -- 코딩 규칙 준수 (2칸 들여쓰기, 작은따옴표, 세미콜론) -- 에러 처리는 항상 포함 -- 선택 옵션이지만 가능한 모든 옵션 구현 권장 - -## 버전 히스토리 - -- **v1.0.0** (2024): 초기 구현 및 PMK 화면 적용 -