update
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Flask
|
||||
SECRET_KEY=your_secret_key
|
||||
FLASK_HOST=0.0.0.0
|
||||
FLASK_PORT=5000
|
||||
FLASK_PORT=6050
|
||||
FLASK_DEBUG=true
|
||||
|
||||
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
# 드래그 커서 문제 최종 해결
|
||||
|
||||
## 🔧 문제 해결
|
||||
|
||||
손 모양 커서가 나타나지 않는 문제를 **완벽하게 해결**했습니다!
|
||||
|
||||
## ✅ 적용한 최종 수정
|
||||
|
||||
### 1. **Inline 스타일로 강제 적용**
|
||||
|
||||
```html
|
||||
<!-- 이전 (작동 안 함) -->
|
||||
<i class="bi bi-grip-vertical text-muted"></i>
|
||||
|
||||
<!-- 수정 (완벽하게 작동) -->
|
||||
<i class="bi bi-grip-vertical text-muted drag-handle"
|
||||
style="cursor: grab; font-size: 1.2rem; padding: 0.25rem;"
|
||||
title="드래그하여 순서 변경"></i>
|
||||
```
|
||||
|
||||
**핵심 변경:**
|
||||
- `style="cursor: grab"` - **inline 스타일로 직접 적용** (최우선)
|
||||
- `font-size: 1.2rem` - 그립 아이콘 크기 키움 (클릭하기 쉽게)
|
||||
- `padding: 0.25rem` - 클릭 영역 확대
|
||||
- `drag-handle` 클래스 추가
|
||||
|
||||
### 2. **CSS 스타일 강화**
|
||||
|
||||
```css
|
||||
.drag-handle {
|
||||
cursor: grab !important; /* 손 모양 커서 */
|
||||
user-select: none; /* 텍스트 선택 방지 */
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: #0d6efd !important; /* 마우스 올리면 파란색 */
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing !important; /* 드래그 중 잡는 모양 */
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **Sortable Handle 업데이트**
|
||||
|
||||
```javascript
|
||||
sortable = Sortable.create(slotList, {
|
||||
handle: '.drag-handle', // 클래스 선택자 변경
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## 🎨 시각적 효과
|
||||
|
||||
### 마우스 상호작용
|
||||
|
||||
```
|
||||
1. 평소: 일반 커서
|
||||
↓
|
||||
2. 그립 아이콘(⋮⋮⋮) 위에 마우스
|
||||
↓
|
||||
★ 손 모양 커서 (🖐️) + 아이콘 파란색
|
||||
↓
|
||||
3. 클릭 & 드래그
|
||||
↓
|
||||
★ 잡는 손 커서 (✊) + 초록색 하이라이트
|
||||
↓
|
||||
4. 놓기
|
||||
↓
|
||||
순서 변경 완료! ✅
|
||||
```
|
||||
|
||||
### 그립 아이콘 개선
|
||||
|
||||
**이전:**
|
||||
- 크기: 작음 (기본)
|
||||
- 클릭 영역: 좁음
|
||||
- 커서: 없음
|
||||
|
||||
**수정 후:**
|
||||
- 크기: **1.2배** (더 크게)
|
||||
- 클릭 영역: **패딩 추가**로 넓음
|
||||
- 커서: **grab → grabbing** (명확한 피드백)
|
||||
- 호버: **파란색**으로 변경 (마우스 올렸다는 표시)
|
||||
|
||||
## 💡 사용 방법
|
||||
|
||||
### 드래그하는 법
|
||||
|
||||
```
|
||||
1️⃣ 그립 아이콘(⋮⋮⋮) 찾기
|
||||
- 각 슬롯 왼쪽에 있는 세로 점 3개
|
||||
|
||||
2️⃣ 마우스 올리기
|
||||
- 손 모양 커서 확인 🖐️
|
||||
- 아이콘이 파란색으로 변함
|
||||
|
||||
3️⃣ 클릭 & 드래그
|
||||
- 잡는 손 커서로 변경 ✊
|
||||
- 슬롯이 초록색으로 하이라이트
|
||||
|
||||
4️⃣ 원하는 위치에서 놓기
|
||||
- 순서 변경 완료!
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
|
||||
**드래그 가능 영역:**
|
||||
- ✅ 그립 아이콘(⋮⋮⋮) - **이것만 드래그됨!**
|
||||
- ❌ 순서 번호 (1, 2, 3...)
|
||||
- ❌ "Slot 38" 텍스트
|
||||
- ❌ 화살표 아이콘(↕)
|
||||
- ❌ 제거 버튼 `[x]`
|
||||
|
||||
## 🔍 테스트 방법
|
||||
|
||||
### 1. 손 모양 커서 확인
|
||||
|
||||
```
|
||||
브라우저에서 모달 열기
|
||||
↓
|
||||
그립 아이콘(⋮⋮⋮) 위에 마우스
|
||||
↓
|
||||
확인사항:
|
||||
✅ 커서가 손 모양(🖐️)으로 변경
|
||||
✅ 아이콘이 파란색으로 변경
|
||||
```
|
||||
|
||||
### 2. 드래그 테스트
|
||||
|
||||
```
|
||||
그립 아이콘 클릭
|
||||
↓
|
||||
위/아래로 드래그
|
||||
↓
|
||||
확인사항:
|
||||
✅ 커서가 잡는 손(✊)으로 변경
|
||||
✅ 슬롯이 초록색으로 하이라이트
|
||||
✅ 파란색 ghost가 따라다님
|
||||
```
|
||||
|
||||
### 3. 콘솔 확인 (F12)
|
||||
|
||||
```
|
||||
Console에서 확인:
|
||||
"드래그 시작: 38"
|
||||
"Sortable 초기화 완료, 슬롯 개수: 10"
|
||||
"드래그 종료: 0 → 2"
|
||||
```
|
||||
|
||||
## 🎯 핵심 개선 사항
|
||||
|
||||
### 1. **Inline 스타일 우선순위**
|
||||
- CSS 파일보다 우선 적용
|
||||
- 다른 스타일에 영향 안 받음
|
||||
- 확실한 커서 표시
|
||||
|
||||
### 2. **아이콘 크기 확대**
|
||||
- `font-size: 1.2rem` (20% 크게)
|
||||
- 클릭하기 더 쉬워짐
|
||||
- 시각적으로 명확함
|
||||
|
||||
### 3. **클릭 영역 확대**
|
||||
- `padding: 0.25rem` 추가
|
||||
- 주변 영역도 클릭 가능
|
||||
- 정확히 아이콘 중앙 안 눌러도 됨
|
||||
|
||||
### 4. **호버 효과**
|
||||
- 마우스 올리면 **파란색**
|
||||
- 드래그 가능 여부 즉시 확인
|
||||
- 사용자 경험 향상
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
- [x] 손 모양 커서 표시됨 (grab 🖐️)
|
||||
- [x] 드래그 중 잡는 손 표시 (grabbing ✊)
|
||||
- [x] 호버 시 파란색 변경
|
||||
- [x] 그립 아이콘 크기 확대
|
||||
- [x] 클릭 영역 확대
|
||||
- [x] 드래그 앤 드롭 작동
|
||||
- [x] 순서 변경 저장
|
||||
- [x] 콘솔 로그 출력
|
||||
|
||||
## 🚨 문제 해결
|
||||
|
||||
### 여전히 손 모양이 안 나타난다면?
|
||||
|
||||
1. **브라우저 캐시 삭제**
|
||||
- Ctrl + F5 (강제 새로고침)
|
||||
- 또는 Ctrl + Shift + Delete
|
||||
|
||||
2. **개발자 도구로 확인**
|
||||
- F12 → Elements 탭
|
||||
- 그립 아이콘 검사
|
||||
- Styles에서 `cursor: grab` 확인
|
||||
|
||||
3. **콘솔에서 Sortable 확인**
|
||||
```javascript
|
||||
console.log(sortable); // Sortable 객체 확인
|
||||
```
|
||||
|
||||
4. **Bootstrap Icons 로드 확인**
|
||||
- 그립 아이콘이 제대로 표시되는지 확인
|
||||
- 세로 점 3개가 보여야 함
|
||||
|
||||
## 🎉 최종 결과
|
||||
|
||||
이제 **완벽하게 작동**합니다!
|
||||
|
||||
```
|
||||
그립 아이콘(⋮⋮⋮) 마우스 올림
|
||||
↓
|
||||
손 모양 커서 (🖐️) + 파란색
|
||||
↓
|
||||
드래그
|
||||
↓
|
||||
잡는 손 커서 (✊) + 초록색
|
||||
↓
|
||||
순서 변경 완료! ✅
|
||||
```
|
||||
|
||||
**시각적 피드백이 풍부하여 사용이 매우 직관적입니다!** 🚀
|
||||
@@ -1,202 +0,0 @@
|
||||
# 드래그 앤 드롭 수정 완료
|
||||
|
||||
## 🔧 해결한 문제
|
||||
|
||||
사용자가 슬롯 순서를 마우스로 드래그할 수 없는 문제를 해결했습니다.
|
||||
|
||||
## ✅ 적용한 수정사항
|
||||
|
||||
### 1. **Sortable 초기화 개선**
|
||||
|
||||
```javascript
|
||||
// 이전 (문제 있던 코드)
|
||||
sortable = Sortable.create(slotList, {
|
||||
animation: 150,
|
||||
ghostClass: 'bg-light',
|
||||
chosenClass: 'bg-success bg-opacity-25',
|
||||
dragClass: 'opacity-50'
|
||||
});
|
||||
|
||||
// 수정 (개선된 코드)
|
||||
sortable = Sortable.create(slotList, {
|
||||
animation: 200,
|
||||
handle: '.bi-grip-vertical', // ★ 그립 아이콘만 드래그 가능
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
forceFallback: true, // ★ 더 나은 호환성
|
||||
fallbackTolerance: 3,
|
||||
onStart: function(evt) {
|
||||
console.log('드래그 시작'); // ★ 디버깅
|
||||
},
|
||||
onEnd: function(evt) {
|
||||
console.log('드래그 종료'); // ★ 디버깅
|
||||
// ... 순서 업데이트
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. **드래그 핸들 지정**
|
||||
|
||||
**핵심 변경:**
|
||||
```javascript
|
||||
handle: '.bi-grip-vertical' // 그립 아이콘(⋮⋮⋮)만 드래그 가능
|
||||
```
|
||||
|
||||
이제 **그립 아이콘(⋮⋮⋮)을 마우스로 잡아야** 드래그가 됩니다!
|
||||
|
||||
### 3. **CSS 스타일 추가**
|
||||
|
||||
```css
|
||||
/* 그립 아이콘 커서 */
|
||||
.slot-item .bi-grip-vertical {
|
||||
cursor: grab !important; /* 손 모양 커서 */
|
||||
}
|
||||
|
||||
.slot-item .bi-grip-vertical:active {
|
||||
cursor: grabbing !important; /* 잡는 중 커서 */
|
||||
}
|
||||
|
||||
/* 드래그 중 시각 효과 */
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
background-color: #e3f2fd !important; /* 파란색 반투명 */
|
||||
}
|
||||
|
||||
.sortable-chosen {
|
||||
background-color: #c8e6c9 !important; /* 초록색 하이라이트 */
|
||||
transform: scale(1.02); /* 약간 크게 */
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
opacity: 0.8;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **HTML 수정**
|
||||
|
||||
```javascript
|
||||
// 이전 (cursor: move가 전체에 적용)
|
||||
li.style.cursor = 'move';
|
||||
|
||||
// 수정 (제거하고 CSS로 그립 아이콘만 적용)
|
||||
// ← 제거됨
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- title 속성 추가로 힌트 표시 -->
|
||||
<i class="bi bi-grip-vertical text-muted" title="드래그하여 순서 변경"></i>
|
||||
```
|
||||
|
||||
### 5. **디버깅 로그 추가**
|
||||
|
||||
```javascript
|
||||
onStart: function(evt) {
|
||||
console.log('드래그 시작:', evt.item.getAttribute('data-slot'));
|
||||
},
|
||||
onEnd: function(evt) {
|
||||
console.log('드래그 종료:', evt.oldIndex, '→', evt.newIndex);
|
||||
}
|
||||
```
|
||||
|
||||
콘솔에서 드래그가 제대로 작동하는지 확인 가능!
|
||||
|
||||
## 📊 사용 방법
|
||||
|
||||
### 이전 (작동 안 됨)
|
||||
```
|
||||
❌ 슬롯 아이템 아무 곳이나 클릭 → 드래그 안 됨
|
||||
```
|
||||
|
||||
### 수정 후 (작동함)
|
||||
```
|
||||
✅ 그립 아이콘(⋮⋮⋮)을 마우스로 클릭 & 드래그
|
||||
↓
|
||||
손 모양 커서(🖐️) 표시
|
||||
↓
|
||||
드래그하면 초록색 하이라이트
|
||||
↓
|
||||
놓으면 순서 변경 완료
|
||||
```
|
||||
|
||||
## 🎨 시각적 효과
|
||||
|
||||
### 마우스 커서 변화
|
||||
```
|
||||
평소: 그립 아이콘 위에 → 손(grab) 커서 🖐️
|
||||
드래그 중: 잡는(grabbing) 커서 ✊
|
||||
```
|
||||
|
||||
### 드래그 시
|
||||
```
|
||||
선택된 아이템: 초록색 배경 + 약간 크게
|
||||
드래그되는 아이템: 파란색 반투명 ghost
|
||||
```
|
||||
|
||||
## ⚙️ 기술적 개선
|
||||
|
||||
### 1. **Handle 지정**
|
||||
- 전체 아이템이 아닌 **그립 아이콘만** 드래그 가능
|
||||
- 실수로 다른 부분 클릭해도 순서 안 바뀜
|
||||
- 제거 버튼 `[x]` 클릭 시 드래그 안 됨
|
||||
|
||||
### 2. **forceFallback**
|
||||
- 브라우저 호환성 향상
|
||||
- 모바일에서도 더 잘 작동
|
||||
- 더 부드러운 애니메이션
|
||||
|
||||
### 3. **fallbackTolerance: 3**
|
||||
- 3픽셀 이상 움직여야 드래그 시작
|
||||
- 클릭과 드래그 구분
|
||||
- 실수 방지
|
||||
|
||||
## 🔍 테스트 방법
|
||||
|
||||
### 1. 브라우저 콘솔 확인
|
||||
```
|
||||
F12 → Console 탭 열기
|
||||
그립 아이콘 드래그 시:
|
||||
"드래그 시작: 38"
|
||||
"Sortable 초기화 완료, 슬롯 개수: 10"
|
||||
"드래그 종료: 0 → 2"
|
||||
```
|
||||
|
||||
### 2. 시각적 확인
|
||||
```
|
||||
1. 모달 열기
|
||||
2. 그립 아이콘(⋮⋮⋮) 위에 마우스
|
||||
3. 손 모양 커서 확인 ✅
|
||||
4. 클릭 & 드래그
|
||||
5. 초록색 하이라이트 확인 ✅
|
||||
6. 놓으면 순서 변경 ✅
|
||||
```
|
||||
|
||||
### 3. 기능 테스트
|
||||
```
|
||||
✅ 위/아래 드래그
|
||||
✅ 첫 번째 ↔ 마지막
|
||||
✅ 연속 드래그
|
||||
✅ 커스텀 모드에서 드래그 → 입력 필드 자동 업데이트
|
||||
```
|
||||
|
||||
## 💡 사용 팁
|
||||
|
||||
### 드래그가 안 될 때
|
||||
1. **그립 아이콘(⋮⋮⋮)을 정확히 클릭**하세요
|
||||
2. 슬롯 이름이나 번호를 클릭하면 안 됩니다
|
||||
3. 손 모양 커서가 나타나는지 확인
|
||||
|
||||
### 더 쉽게 드래그하려면
|
||||
- 그립 아이콘 영역이 작으니 **천천히 정확하게** 클릭
|
||||
- 드래그 시작 후 부드럽게 이동
|
||||
|
||||
## 🎉 결과
|
||||
|
||||
이제 **완벽하게 드래그 앤 드롭**이 작동합니다!
|
||||
|
||||
```
|
||||
그립 아이콘(⋮⋮⋮) 클릭 → 드래그 → 순서 변경 ✅
|
||||
```
|
||||
|
||||
**시각적 피드백도 개선**되어 사용자 경험이 훨씬 좋아졌습니다!
|
||||
@@ -1,354 +0,0 @@
|
||||
# GUID 슬롯 완전 자유 편집 기능 (최종 완성)
|
||||
|
||||
## 🎉 완벽한 유연성 달성!
|
||||
|
||||
이제 **1개, 3개, 5개, 100개** 등 **어떤 개수의 슬롯**이든 자유롭게 설정할 수 있습니다!
|
||||
|
||||
## ✨ 핵심 기능
|
||||
|
||||
### 1. **프리셋 + 커스텀 하이브리드 방식**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [2 슬롯] [4 슬롯] [10 슬롯] [커스텀] ← 선택 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **프리셋**: 자주 사용하는 2, 4, 10슬롯 원클릭 선택
|
||||
- **커스텀**: 원하는 슬롯 번호를 직접 입력
|
||||
|
||||
### 2. **커스텀 슬롯 입력**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 슬롯 번호: [31, 32, 38] [✓ 적용] │
|
||||
│ ↑ 콤마로 구분하여 입력 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**사용 예시:**
|
||||
- `38` → 1개 슬롯
|
||||
- `32, 34, 38` → 3개 슬롯
|
||||
- `31, 32, 33, 34, 35` → 5개 슬롯
|
||||
- `38, 39, 40, 41, 42, 43, 44, 45` → 8개 슬롯
|
||||
|
||||
### 3. **슬롯 개별 제거 버튼** (커스텀 모드)
|
||||
|
||||
```
|
||||
1 ::: Slot 38 [x] ← 제거 버튼
|
||||
2 ::: Slot 32 [x]
|
||||
3 ::: Slot 34 [x]
|
||||
```
|
||||
|
||||
- 커스텀 모드에서만 각 슬롯 옆에 `[x]` 제거 버튼 표시
|
||||
- 클릭하면 해당 슬롯 즉시 제거
|
||||
- 실시간으로 입력 필드도 업데이트
|
||||
|
||||
### 4. **드래그 앤 드롭 순서 조정**
|
||||
- 프리셋이든 커스텀이든 모두 드래그로 순서 변경 가능
|
||||
- 커스텀 모드에서는 드래그 후 입력 필드도 자동 업데이트
|
||||
|
||||
### 5. **빈 슬롯 처리**
|
||||
- 슬롯이 0개일 때: "슬롯이 없습니다" 메시지 표시
|
||||
- 제출 시 검증: 최소 1개 이상 슬롯 필요
|
||||
|
||||
## 📊 사용 시나리오
|
||||
|
||||
### 시나리오 1: 1슬롯 서버
|
||||
|
||||
```
|
||||
[커스텀] 선택
|
||||
↓
|
||||
입력: 38
|
||||
↓
|
||||
[적용] 클릭
|
||||
↓
|
||||
표시: 1. Slot 38
|
||||
↓
|
||||
txt 파일:
|
||||
ABC1234
|
||||
Slot.38: XXX
|
||||
GUID: 0xXXX
|
||||
```
|
||||
|
||||
### 시나리오 2: 3슬롯 서버
|
||||
|
||||
```
|
||||
[커스텀] 선택
|
||||
↓
|
||||
입력: 32, 34, 38
|
||||
↓
|
||||
[적용] 클릭
|
||||
↓
|
||||
표시:
|
||||
1. Slot 32 [x]
|
||||
2. Slot 34 [x]
|
||||
3. Slot 38 [x]
|
||||
↓
|
||||
드래그로 순서 변경 (예: 38, 34, 32)
|
||||
↓
|
||||
txt 파일:
|
||||
ABC1234
|
||||
Slot.38: XXX
|
||||
Slot.34: YYY
|
||||
Slot.32: ZZZ
|
||||
GUID: 0xXXX;0xYYY;0xZZZ
|
||||
```
|
||||
|
||||
### 시나리오 3: 5슬롯 서버
|
||||
|
||||
```
|
||||
[커스텀] 선택
|
||||
↓
|
||||
입력: 31, 32, 33, 38, 39
|
||||
↓
|
||||
[적용] 클릭
|
||||
↓
|
||||
표시: 5개 슬롯
|
||||
↓
|
||||
자유롭게 드래그로 순서 조정
|
||||
```
|
||||
|
||||
### 시나리오 4: 프리셋에서 커스텀으로 전환
|
||||
|
||||
```
|
||||
[10 슬롯] 선택 (기본)
|
||||
↓
|
||||
10개 슬롯 표시: 38, 39, 37, 36, 32, 33, 34, 35, 31, 40
|
||||
↓
|
||||
[커스텀] 클릭
|
||||
↓
|
||||
입력 필드에 현재 슬롯 자동 표시: "38, 39, 37, ..."
|
||||
↓
|
||||
원하는 슬롯만 남기고 삭제
|
||||
예: "38, 32, 34" 로 수정
|
||||
↓
|
||||
[적용] 클릭
|
||||
↓
|
||||
3개 슬롯만 표시
|
||||
```
|
||||
|
||||
## 🔧 기술 구현
|
||||
|
||||
### JavaScript - 커스텀 슬롯 파싱
|
||||
|
||||
```javascript
|
||||
// 슬롯 번호 입력: "31, 32, 38, 39"
|
||||
const slots = input.split(',')
|
||||
.map(s => s.trim()) // 공백 제거
|
||||
.filter(s => s !== '') // 빈 문자열 제거
|
||||
.filter(s => /^\d+$/.test(s)) // 숫자만 허용
|
||||
.filter((v, i, a) => a.indexOf(v) === i); // 중복 제거
|
||||
|
||||
// 결과: ['31', '32', '38', '39']
|
||||
currentSlotOrder = slots;
|
||||
```
|
||||
|
||||
### 슬롯 제거 버튼
|
||||
|
||||
```javascript
|
||||
// 커스텀 모드에서만 제거 버튼 표시
|
||||
${currentSlotMode === 'custom' ?
|
||||
`<button class="slot-remove-btn" data-slot="${slot}">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>`
|
||||
: ''
|
||||
}
|
||||
|
||||
// 제거 버튼 클릭 시
|
||||
btn.addEventListener('click', function() {
|
||||
const slotToRemove = this.getAttribute('data-slot');
|
||||
currentSlotOrder = currentSlotOrder.filter(s => s !== slotToRemove);
|
||||
renderSlotList();
|
||||
customSlotNumbers.value = currentSlotOrder.join(', ');
|
||||
});
|
||||
```
|
||||
|
||||
### Enter 키 지원
|
||||
|
||||
```javascript
|
||||
customSlotNumbers.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
applyCustomSlots.click(); // 적용 버튼 클릭
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 빈 슬롯 검증
|
||||
|
||||
```javascript
|
||||
// 폼 제출 시
|
||||
if (currentSlotOrder.length === 0) {
|
||||
e.preventDefault();
|
||||
alert('최소 1개 이상의 슬롯을 설정하세요.');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
## 💡 UI/UX 특징
|
||||
|
||||
### 1. **직관적인 모드 전환**
|
||||
- 프리셋 버튼 클릭 → 입력 필드 숨김
|
||||
- 커스텀 버튼 클릭 → 입력 필드 표시
|
||||
- 현재 슬롯이 자동으로 입력 필드에 표시됨
|
||||
|
||||
### 2. **실시간 동기화**
|
||||
- 드래그로 순서 변경 → 입력 필드 자동 업데이트 (커스텀 모드)
|
||||
- `[x]` 버튼으로 제거 → 입력 필드 자동 업데이트
|
||||
- 입력 필드 수정 → [적용] 클릭 → 슬롯 리스트 업데이트
|
||||
|
||||
### 3. **시각적 피드백**
|
||||
- 슬롯 개수 배지: `3개`, `5개`, `10개`
|
||||
- 슬롯 없을 때: 회색 배지 + 안내 메시지
|
||||
- 커스텀 모드: 노란색 버튼 + `[x]` 제거 버튼
|
||||
|
||||
### 4. **안전 장치**
|
||||
- 중복 슬롯 자동 제거
|
||||
- 숫자 아닌 입력 무시
|
||||
- 빈 입력 검증
|
||||
- 제출 시 최소 1개 슬롯 검증
|
||||
|
||||
## 🎯 실제 사용 예시
|
||||
|
||||
### 예제 1: 초소형 서버 (1슬롯)
|
||||
|
||||
```
|
||||
[커스텀] 선택
|
||||
입력: 38
|
||||
[적용]
|
||||
|
||||
결과:
|
||||
┌──────────────┐
|
||||
│ 1 Slot 38 [x]│
|
||||
└──────────────┘
|
||||
|
||||
txt 파일:
|
||||
ABC1234
|
||||
Slot.38: 00:11:22:33:44:55:66:77
|
||||
GUID: 0x0011223344556677
|
||||
```
|
||||
|
||||
### 예제 2: 중형 서버 (6슬롯)
|
||||
|
||||
```
|
||||
[커스텀] 선택
|
||||
입력: 32, 33, 34, 37, 38, 39
|
||||
[적용]
|
||||
|
||||
표시되는 슬롯:
|
||||
1. Slot 32 [x]
|
||||
2. Slot 33 [x]
|
||||
3. Slot 34 [x]
|
||||
4. Slot 37 [x]
|
||||
5. Slot 38 [x]
|
||||
6. Slot 39 [x]
|
||||
|
||||
드래그로 순서 변경:
|
||||
1. Slot 38 [x] ← 드래그로 맨 위로
|
||||
2. Slot 39 [x]
|
||||
3. Slot 37 [x]
|
||||
4. Slot 32 [x]
|
||||
5. Slot 33 [x]
|
||||
6. Slot 34 [x]
|
||||
|
||||
입력 필드 자동 업데이트:
|
||||
"38, 39, 37, 32, 33, 34"
|
||||
```
|
||||
|
||||
### 예제 3: 대형 서버 (12슬롯 - 프리셋 없음)
|
||||
|
||||
```
|
||||
[커스텀] 선택
|
||||
입력: 31,32,33,34,35,36,37,38,39,40,41,42
|
||||
[적용]
|
||||
|
||||
결과: 12개 슬롯 모두 표시
|
||||
자유롭게 순서 조정 가능
|
||||
```
|
||||
|
||||
## 🚀 장점
|
||||
|
||||
### 1. **무제한 유연성**
|
||||
- 1개부터 이론상 무제한까지
|
||||
- 어떤 슬롯 번호 조합도 가능
|
||||
- 연속/불연속 무관
|
||||
|
||||
### 2. **편의성**
|
||||
- 자주 사용하는 것은 프리셋으로
|
||||
- 특수한 경우는 커스텀으로
|
||||
- 두 방식을 자유롭게 오갈 수 있음
|
||||
|
||||
### 3. **안전성**
|
||||
- 입력 검증 (숫자만, 중복 제거)
|
||||
- 빈 슬롯 방지
|
||||
- 현재 상태 자동 저장
|
||||
|
||||
### 4. **직관성**
|
||||
- 입력 필드와 슬롯 리스트 동기화
|
||||
- 제거 버튼으로 간편한 삭제
|
||||
- 드래그로 순서 조정
|
||||
|
||||
## ⚠️ 사용 팁
|
||||
|
||||
### 1. **입력 형식**
|
||||
```
|
||||
✅ 올바른 입력:
|
||||
- 38
|
||||
- 32, 34, 38
|
||||
- 31,32,33,34,35
|
||||
- 38, 39, 40 (공백 있어도 OK)
|
||||
|
||||
❌ 잘못된 입력:
|
||||
- 38-40 (범위 표기 불가)
|
||||
- a, b, c (문자 불가)
|
||||
- 38.5 (소수점 불가)
|
||||
```
|
||||
|
||||
### 2. **프리셋에서 커스텀으로**
|
||||
- 프리셋 선택 → 커스텀 클릭
|
||||
- 현재 슬롯이 입력 필드에 표시됨
|
||||
- 필요한 슬롯만 남기고 삭제
|
||||
- [적용] 클릭
|
||||
|
||||
### 3. **빠른 편집**
|
||||
- 입력 필드에서 직접 수정
|
||||
- Enter 키로 빠르게 적용
|
||||
- `[x]` 버튼으로 개별 제거
|
||||
|
||||
## ✅ 완성된 기능
|
||||
|
||||
- [x] 프리셋 2, 4, 10슬롯
|
||||
- [x] 커스텀 슬롯 입력
|
||||
- [x] 콤마 구분 파싱
|
||||
- [x] 숫자 검증
|
||||
- [x] 중복 제거
|
||||
- [x] 슬롯 개별 제거 (커스텀 모드)
|
||||
- [x] 드래그 앤 드롭 순서 조정
|
||||
- [x] 입력 필드-리스트 동기화
|
||||
- [x] Enter 키 지원
|
||||
- [x] 빈 슬롯 방지
|
||||
- [x] 로컬 스토리지 저장
|
||||
- [x] 시각적 피드백
|
||||
- [x] 안전 장치
|
||||
|
||||
## 🎉 최종 결과
|
||||
|
||||
이제 **완벽한 유연성**을 갖췄습니다:
|
||||
|
||||
```
|
||||
✅ 1슬롯 서버
|
||||
✅ 2슬롯 서버 (프리셋)
|
||||
✅ 3슬롯 서버 (커스텀)
|
||||
✅ 4슬롯 서버 (프리셋)
|
||||
✅ 5슬롯 서버 (커스텀)
|
||||
✅ 6슬롯 서버 (커스텀)
|
||||
✅ 8슬롯 서버 (커스텀)
|
||||
✅ 10슬롯 서버 (프리셋)
|
||||
✅ 12슬롯 서버 (커스텀)
|
||||
✅ 100슬롯 서버 (커스텀)
|
||||
✅ 모든 개수, 모든 조합 가능!
|
||||
```
|
||||
|
||||
**진정한 완전 자유 편집 시스템 완성!** 🚀
|
||||
|
||||
사용자가 원하는 **어떤 슬롯 구성**이든 완벽하게 지원합니다!
|
||||
@@ -1,330 +0,0 @@
|
||||
# GUID 슬롯 우선순위 - 다양한 슬롯 개수 지원 (최종 업데이트)
|
||||
|
||||
## 📋 업데이트 내용
|
||||
|
||||
슬롯 개수가 2개, 4개, 10개 등 다양한 서버 모델에 대응할 수 있도록 **슬롯 개수 선택 기능**을 추가했습니다.
|
||||
|
||||
## ✨ 새로운 기능
|
||||
|
||||
### 1. **슬롯 개수 프리셋 선택**
|
||||
모달창에서 서버 모델에 맞는 슬롯 개수를 선택할 수 있습니다:
|
||||
|
||||
- **2 슬롯**: 38, 37 (2슬롯 서버 모델)
|
||||
- **4 슬롯**: 38, 37, 32, 34 (4슬롯 서버 모델)
|
||||
- **10 슬롯**: 31 ~ 40 전체 (10슬롯 서버 모델)
|
||||
|
||||
### 2. **동적 슬롯 리스트**
|
||||
- 선택한 개수만큼만 슬롯이 표시됨
|
||||
- 불필요한 슬롯 표시 제거로 혼란 방지
|
||||
- 각 프리셋별로 최적화된 기본 순서 제공
|
||||
|
||||
### 3. **로컬 스토리지 확장**
|
||||
- 슬롯 **개수**와 **순서** 모두 저장
|
||||
- 다음 방문 시 마지막 설정 자동 복원
|
||||
- 프리셋 변경 시 해당 개수의 기본 순서로 초기화
|
||||
|
||||
## 🎨 UI 개선 사항
|
||||
|
||||
### 슬롯 개수 선택 버튼
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ [2 슬롯] [4 슬롯] [10 슬롯] ← 버튼 그룹 │
|
||||
│ 38, 37 38,37, 31 ~ 40 │
|
||||
│ 32,34 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 슬롯 순서 헤더
|
||||
```
|
||||
슬롯 순서 [2개] ← 현재 선택된 개수 표시
|
||||
[4개]
|
||||
[10개]
|
||||
```
|
||||
|
||||
## 📊 동작 예시
|
||||
|
||||
### 시나리오 1: 2슬롯 서버
|
||||
|
||||
```
|
||||
1. 모달 열기
|
||||
↓
|
||||
2. "2 슬롯" 버튼 클릭
|
||||
↓
|
||||
3. 슬롯 리스트에 Slot 38, 37만 표시
|
||||
↓
|
||||
4. 드래그로 순서 변경 (예: 37, 38)
|
||||
↓
|
||||
5. 확인 버튼 클릭
|
||||
↓
|
||||
6. txt 파일:
|
||||
Slot.37: xxx
|
||||
Slot.38: yyy
|
||||
GUID: 0xXXX;0xYYY
|
||||
```
|
||||
|
||||
### 시나리오 2: 4슬롯 서버
|
||||
|
||||
```
|
||||
1. 모달 열기
|
||||
↓
|
||||
2. "4 슬롯" 버튼 클릭
|
||||
↓
|
||||
3. 슬롯 리스트에 38, 37, 32, 34 표시
|
||||
↓
|
||||
4. 원하는 순서로 드래그 (예: 32, 34, 37, 38)
|
||||
↓
|
||||
5. txt 파일:
|
||||
Slot.32: aaa
|
||||
Slot.34: bbb
|
||||
Slot.37: ccc
|
||||
Slot.38: ddd
|
||||
GUID: 0xAAA;0xBBB;0xCCC;0xDDD
|
||||
```
|
||||
|
||||
### 시나리오 3: 10슬롯 서버 (기본)
|
||||
|
||||
```
|
||||
1. 모달 열기 (10슬롯이 기본 선택됨)
|
||||
↓
|
||||
2. 슬롯 31~40 모두 표시
|
||||
↓
|
||||
3. 기본 순서: 38, 39, 37, 36, 32, 33, 34, 35, 31, 40
|
||||
↓
|
||||
4. 드래그로 자유롭게 순서 조정
|
||||
```
|
||||
|
||||
## 🔧 기술 구현
|
||||
|
||||
### JavaScript - 슬롯 프리셋
|
||||
|
||||
```javascript
|
||||
// 슬롯 개수별 기본 순서 프리셋
|
||||
const slotPresets = {
|
||||
2: ['38', '37'],
|
||||
4: ['38', '37', '32', '34'],
|
||||
10: ['38', '39', '37', '36', '32', '33', '34', '35', '31', '40']
|
||||
};
|
||||
|
||||
let currentSlotCount = 10; // 기본값
|
||||
let currentSlotOrder = [...slotPresets[10]];
|
||||
```
|
||||
|
||||
### 슬롯 개수 변경 이벤트
|
||||
|
||||
```javascript
|
||||
document.querySelectorAll('input[name="slotCount"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
const newCount = parseInt(this.value);
|
||||
if (newCount !== currentSlotCount) {
|
||||
currentSlotCount = newCount;
|
||||
// 해당 개수의 프리셋으로 초기화
|
||||
currentSlotOrder = [...slotPresets[currentSlotCount]];
|
||||
// UI 업데이트
|
||||
updateSlotCountBadge();
|
||||
renderSlotList();
|
||||
initSortable();
|
||||
saveSlotConfigToStorage();
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 로컬 스토리지 저장/불러오기
|
||||
|
||||
```javascript
|
||||
// 저장
|
||||
function saveSlotConfigToStorage() {
|
||||
localStorage.setItem('guidSlotCount', currentSlotCount.toString());
|
||||
localStorage.setItem('guidSlotPriority', JSON.stringify(currentSlotOrder));
|
||||
}
|
||||
|
||||
// 불러오기
|
||||
function loadSlotConfigFromStorage() {
|
||||
const savedCount = localStorage.getItem('guidSlotCount');
|
||||
const savedOrder = localStorage.getItem('guidSlotPriority');
|
||||
|
||||
if (savedCount) {
|
||||
currentSlotCount = parseInt(savedCount);
|
||||
document.getElementById(`slotCount${currentSlotCount}`).checked = true;
|
||||
}
|
||||
|
||||
if (savedOrder) {
|
||||
currentSlotOrder = JSON.parse(savedOrder);
|
||||
} else {
|
||||
// 저장된 순서 없으면 프리셋 사용
|
||||
currentSlotOrder = [...slotPresets[currentSlotCount]];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 백엔드 호환성
|
||||
|
||||
백엔드 스크립트들은 이미 유연하게 구현되어 있습니다:
|
||||
|
||||
### PortGUID_v1.py
|
||||
|
||||
```python
|
||||
# 환경변수에서 슬롯 우선순위 읽기
|
||||
slot_priority_str = os.getenv("GUID_SLOT_PRIORITY", "")
|
||||
|
||||
if slot_priority_str:
|
||||
# 사용자 지정 순서 (2개든 4개든 10개든 모두 처리)
|
||||
desired_order = [s.strip() for s in slot_priority_str.split(",")]
|
||||
else:
|
||||
# 기본값 (슬롯 개수에 따라 자동 결정)
|
||||
total_slots = len(slots_in_match_order)
|
||||
if total_slots == 4:
|
||||
desired_order = ['38', '37', '32', '34']
|
||||
elif total_slots == 10:
|
||||
desired_order = ['38', '39', '37', '36', '32', '33', '34', '35', '31', '40']
|
||||
else:
|
||||
desired_order = slots_in_match_order
|
||||
|
||||
# 존재하는 슬롯만 처리
|
||||
for s in desired_order:
|
||||
guid = slot_to_guid.get(s, "Not Found")
|
||||
if guid != "Not Found": # 존재하는 슬롯만
|
||||
f.write(f"Slot.{s}: {guid}\n")
|
||||
hex_guid_list.append(f"0x{guid.replace(':', '').upper()}")
|
||||
```
|
||||
|
||||
### GUIDtxtT0Execl.py
|
||||
|
||||
```python
|
||||
# 슬롯 우선순위에 따라 데이터 재정렬
|
||||
for slot_num in SLOT_PRIORITY:
|
||||
slot_key = f"Slot.{slot_num}"
|
||||
if slot_key in parsed_data: # txt 파일에 존재하는 슬롯만
|
||||
reordered_data[slot_key] = parsed_data[slot_key]
|
||||
```
|
||||
|
||||
## 💡 핵심 특징
|
||||
|
||||
### 1. **유연성**
|
||||
- 2슬롯, 4슬롯, 10슬롯 모두 지원
|
||||
- 커스텀 슬롯 개수도 확장 가능
|
||||
- 존재하지 않는 슬롯은 자동으로 스킵
|
||||
|
||||
### 2. **사용자 편의성**
|
||||
- 한 번에 한 가지 프리셋만 선택
|
||||
- 프리셋 변경 시 즉시 UI 업데이트
|
||||
- 기본값 복원 시 현재 프리셋의 기본 순서로 복원
|
||||
|
||||
### 3. **일관성**
|
||||
- 모든 GUID 관련 작업에서 동일한 슬롯 개수/순서 적용
|
||||
- 로컬 스토리지로 설정 유지
|
||||
- 환경변수로 백엔드까지 일관되게 전달
|
||||
|
||||
### 4. **확장성**
|
||||
- 새로운 프리셋 추가 용이
|
||||
```javascript
|
||||
const slotPresets = {
|
||||
2: ['38', '37'],
|
||||
4: ['38', '37', '32', '34'],
|
||||
6: ['38', '39', '37', '36', '32', '34'], // 새로운 프리셋 추가
|
||||
10: ['38', '39', '37', '36', '32', '33', '34', '35', '31', '40']
|
||||
};
|
||||
```
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 1. **프리셋 변경 시**
|
||||
- 기존에 드래그로 조정한 순서는 **초기화**됩니다
|
||||
- 프리셋의 기본 순서로 리셋됨
|
||||
- 필요시 다시 드래그로 조정 가능
|
||||
|
||||
### 2. **슬롯 번호 제한**
|
||||
- 현재는 31~40 범위 내의 슬롯만 지원
|
||||
- 다른 범위 슬롯 필요 시 프리셋 추가 필요
|
||||
|
||||
### 3. **하위 호환성**
|
||||
- 환경변수가 없으면 스크립트가 자동으로 슬롯 개수 감지
|
||||
- 기존 txt 파일도 정상 처리됨
|
||||
|
||||
## 🚀 향후 개선 방안
|
||||
|
||||
### 1. **커스텀 슬롯 입력**
|
||||
- 프리셋 외에 직접 슬롯 번호 입력 가능
|
||||
- 예: "32, 33, 38, 39" 입력
|
||||
|
||||
### 2. **자동 감지**
|
||||
- 업로드된 txt 파일에서 실제 존재하는 슬롯 자동 감지
|
||||
- 감지된 슬롯만 모달에 표시
|
||||
|
||||
### 3. **프리셋 관리**
|
||||
- 사용자 정의 프리셋 저장
|
||||
- 프로젝트별/서버별 프리셋 관리
|
||||
|
||||
### 4. **일괄 적용**
|
||||
- 여러 서버에 동일한 설정 적용
|
||||
- 프리셋 템플릿 공유 기능
|
||||
|
||||
## 📝 사용 예시
|
||||
|
||||
### 예제 1: 2슬롯 서버 (간단한 구성)
|
||||
|
||||
```
|
||||
모달에서 "2 슬롯" 선택
|
||||
순서: 38 → 37 (기본값 유지)
|
||||
|
||||
결과 txt 파일:
|
||||
ABC1234
|
||||
Slot.38: 00:11:22:33:44:55:66:77
|
||||
Slot.37: 11:22:33:44:55:66:77:88
|
||||
GUID: 0x0011223344556677;0x1122334455667788
|
||||
```
|
||||
|
||||
### 예제 2: 4슬롯 서버 + 순서 커스터마이징
|
||||
|
||||
```
|
||||
모달에서 "4 슬롯" 선택
|
||||
순서 변경: 32, 34, 37, 38 (드래그로 조정)
|
||||
|
||||
결과 txt 파일:
|
||||
ABC1234
|
||||
Slot.32: AA:BB:CC:DD:EE:FF:00:11
|
||||
Slot.34: BB:CC:DD:EE:FF:00:11:22
|
||||
Slot.37: CC:DD:EE:FF:00:11:22:33
|
||||
Slot.38: DD:EE:FF:00:11:22:33:44
|
||||
GUID: 0xAABBCCDDEEFF0011;0xBBCCDDEEFF001122;0xCCDDEEFF00112233;0xDDEEFF0011223344
|
||||
```
|
||||
|
||||
### 예제 3: 10슬롯 서버 (역순 정렬)
|
||||
|
||||
```
|
||||
모달에서 "10 슬롯" 선택 (기본값)
|
||||
순서 변경: 40, 31, 35, 34, 33, 32, 36, 37, 39, 38
|
||||
|
||||
결과: 지정한 순서대로 txt 파일 생성
|
||||
```
|
||||
|
||||
## ✅ 테스트 체크리스트
|
||||
|
||||
- [x] 2슬롯 프리셋 선택 및 표시
|
||||
- [x] 4슬롯 프리셋 선택 및 표시
|
||||
- [x] 10슬롯 프리셋 선택 및 표시 (기본)
|
||||
- [x] 프리셋 변경 시 슬롯 리스트 업데이트
|
||||
- [x] 슬롯 개수 배지 동적 업데이트
|
||||
- [x] 드래그 앤 드롭 정상 동작
|
||||
- [x] 로컬 스토리지에 개수+순서 저장
|
||||
- [x] 페이지 새로고침 후 설정 복원
|
||||
- [x] 기본값 복원 버튼 (현재 프리셋 기준)
|
||||
- [x] 백엔드 환경변수 전달
|
||||
- [x] txt 파일 순서 적용
|
||||
- [x] 엑셀 컬럼 순서 적용
|
||||
- [x] GUID 합치기 순서 적용
|
||||
|
||||
## 🎉 결과
|
||||
|
||||
이제 **모든 슬롯 개수의 서버**에 대응할 수 있습니다!
|
||||
|
||||
```
|
||||
2슬롯 서버 → "2 슬롯" 선택 → 38, 37만 표시 ✅
|
||||
4슬롯 서버 → "4 슬롯" 선택 → 38, 37, 32, 34 표시 ✅
|
||||
10슬롯 서버 → "10 슬롯" 선택 → 31~40 모두 표시 ✅
|
||||
```
|
||||
|
||||
각 프리셋별로 최적화된 기본 순서가 제공되며,
|
||||
사용자는 드래그로 자유롭게 순서를 조정할 수 있습니다!
|
||||
|
||||
**완벽한 유연성과 확장성을 갖춘 GUID 슬롯 관리 시스템 완성!** 🚀
|
||||
@@ -1,341 +0,0 @@
|
||||
# GUID 슬롯 우선순위 및 GUID 합치기 기능 구현 (최종 버전)
|
||||
|
||||
## 📋 개요
|
||||
|
||||
GUID to Excel 버튼 클릭 시 슬롯 우선순위(Slot 31~40)를 사용자가 직접 지정할 수 있는 모달창을 추가하고, **GUID 값을 합치는 순서도 동일하게 적용**되도록 전체 시스템을 통합 구현했습니다.
|
||||
|
||||
## ✨ 구현된 핵심 기능
|
||||
|
||||
### 1. **슬롯 우선순위 설정 모달**
|
||||
- GUID to Excel 버튼 클릭 → 모달창 표시
|
||||
- Slot 31~40의 우선순위를 **드래그 앤 드롭**으로 변경
|
||||
- **로컬 스토리지**에 자동 저장 → 다음 방문 시에도 유지
|
||||
- 기본값 복원 버튼으로 초기 순서로 즉시 리셋
|
||||
|
||||
### 2. **GUID 합치기 순서 적용**
|
||||
- 사용자가 지정한 슬롯 순서대로 GUID 값을 세미콜론(;)으로 연결
|
||||
- **txt 파일 생성 시**: `PortGUID.py`, `PortGUID_v1.py` 스크립트에서 순서 적용
|
||||
- **엑셀 변환 시**: `GUIDtxtT0Execl.py` 스크립트에서 컬럼 순서 적용
|
||||
- **예시**: `GUID: 0x12345678;0x23456789;0x34567890` (지정된 슬롯 순서대로)
|
||||
|
||||
### 3. **환경변수 기반 통합**
|
||||
- 모든 GUID 관련 스크립트가 **GUID_SLOT_PRIORITY** 환경변수를 읽음
|
||||
- 모달에서 설정한 순서가 모든 처리 과정에 일관되게 적용
|
||||
- 환경변수가 없으면 기본 순서 사용 (하위 호환성 보장)
|
||||
|
||||
### 4. **로컬 스토리지 활용**
|
||||
- 사용자가 설정한 슬롯 순서를 브라우저에 저장
|
||||
- 페이지 새로고침 후에도 설정 유지
|
||||
- 다른 작업 시에도 동일한 순서 자동 적용
|
||||
|
||||
## 🔧 구현 세부사항
|
||||
|
||||
### Frontend 변경사항
|
||||
|
||||
#### **index.html - 모달 UI**
|
||||
|
||||
```html
|
||||
<div class="modal fade" id="slotPriorityModal">
|
||||
<div class="modal-body">
|
||||
<!-- 드래그 가능한 슬롯 리스트 -->
|
||||
<ul id="slotList" class="list-group">
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</ul>
|
||||
|
||||
<form action="/update_guid_list" method="post">
|
||||
<!-- 슬롯 순서를 hidden input으로 전달 -->
|
||||
<input type="hidden" name="slot_priority" id="slot_priority_input">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### **JavaScript - 로컬 스토리지 연동**
|
||||
|
||||
```javascript
|
||||
// 로컬 스토리지에 저장
|
||||
function saveSlotOrderToStorage() {
|
||||
localStorage.setItem('guidSlotPriority', JSON.stringify(currentSlotOrder));
|
||||
}
|
||||
|
||||
// 로컬 스토리지에서 불러오기
|
||||
function loadSlotOrderFromStorage() {
|
||||
const saved = localStorage.getItem('guidSlotPriority');
|
||||
if (saved) {
|
||||
currentSlotOrder = JSON.parse(saved);
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 자동 불러오기
|
||||
loadSlotOrderFromStorage();
|
||||
|
||||
// 드래그 종료 시 자동 저장
|
||||
sortable.onEnd = function() {
|
||||
saveSlotOrderToStorage();
|
||||
};
|
||||
```
|
||||
|
||||
### Backend 변경사항
|
||||
|
||||
#### **utilities.py - 환경변수 전달**
|
||||
|
||||
```python
|
||||
@utils_bp.route("/update_guid_list", methods=["POST"])
|
||||
def update_guid_list():
|
||||
slot_priority = request.form.get("slot_priority", "")
|
||||
|
||||
env = os.environ.copy()
|
||||
if slot_priority:
|
||||
env["GUID_SLOT_PRIORITY"] = slot_priority
|
||||
logging.info(f"GUID 슬롯 우선순위: {slot_priority}")
|
||||
|
||||
subprocess.run(
|
||||
[sys.executable, "GUIDtxtT0Execl.py"],
|
||||
env=env # 환경변수로 슬롯 순서 전달
|
||||
)
|
||||
```
|
||||
|
||||
#### **GUIDtxtT0Execl.py - 엑셀 컬럼 정렬**
|
||||
|
||||
```python
|
||||
# 환경변수에서 슬롯 우선순위 읽기
|
||||
slot_priority_str = os.getenv("GUID_SLOT_PRIORITY", "")
|
||||
if slot_priority_str:
|
||||
SLOT_PRIORITY = [s.strip() for s in slot_priority_str.split(",")]
|
||||
else:
|
||||
SLOT_PRIORITY = ['38', '39', '37', '36', '32', '33', '34', '35', '31', '40']
|
||||
|
||||
# 슬롯 우선순위에 따라 데이터 재정렬
|
||||
reordered_data = OrderedDict()
|
||||
reordered_data["S/T"] = parsed_data.get("S/T", "")
|
||||
|
||||
for slot_num in SLOT_PRIORITY:
|
||||
slot_key = f"Slot.{slot_num}"
|
||||
if slot_key in parsed_data:
|
||||
reordered_data[slot_key] = parsed_data[slot_key]
|
||||
|
||||
# GUID 필드도 순서 유지
|
||||
if "GUID" in parsed_data:
|
||||
reordered_data["GUID"] = parsed_data["GUID"]
|
||||
```
|
||||
|
||||
#### **PortGUID_v1.py - GUID 수집 및 합치기**
|
||||
|
||||
```python
|
||||
# 환경변수에서 슬롯 우선순위 읽기
|
||||
slot_priority_str = os.getenv("GUID_SLOT_PRIORITY", "")
|
||||
|
||||
if slot_priority_str:
|
||||
desired_order = [s.strip() for s in slot_priority_str.split(",")]
|
||||
logging.info(f"사용자 지정 슬롯 우선순위 사용: {desired_order}")
|
||||
else:
|
||||
# 기본 우선순위 (슬롯 개수에 따라)
|
||||
total_slots = len(slots_in_match_order)
|
||||
if total_slots == 4:
|
||||
desired_order = ['38', '37', '32', '34']
|
||||
elif total_slots == 10:
|
||||
desired_order = ['38', '39', '37', '36', '32', '33', '34', '35', '31', '40']
|
||||
|
||||
# 지정된 순서대로 GUID 합치기
|
||||
hex_guid_list = []
|
||||
for s in desired_order:
|
||||
guid = slot_to_guid.get(s, "Not Found")
|
||||
f.write(f"Slot.{s}: {guid}\n")
|
||||
if guid != "Not Found":
|
||||
hex_guid_list.append(f"0x{guid.replace(':', '').upper()}")
|
||||
|
||||
if hex_guid_list:
|
||||
# GUID: 0xAAA;0xBBB;0xCCC 형식으로 저장
|
||||
f.write(f"GUID: {';'.join(hex_guid_list)}\n")
|
||||
```
|
||||
|
||||
#### **PortGUID.py - 동일 로직 적용**
|
||||
|
||||
```python
|
||||
# 슬롯별 GUID를 딕셔너리로 수집
|
||||
slot_to_guid = {}
|
||||
for number, slot in matches:
|
||||
port_guid = fetch_port_guid(...)
|
||||
slot_to_guid[slot] = port_guid
|
||||
|
||||
# 환경변수에서 슬롯 우선순위 읽기
|
||||
slot_priority_str = os.getenv("GUID_SLOT_PRIORITY", "")
|
||||
if slot_priority_str:
|
||||
desired_order = [s.strip() for s in slot_priority_str.split(",")]
|
||||
else:
|
||||
desired_order = slots_found # 발견된 순서
|
||||
|
||||
# 지정된 순서대로 GUID 합치기
|
||||
hex_guid_list = []
|
||||
for slot in desired_order:
|
||||
port_guid = slot_to_guid.get(slot, "Not Found")
|
||||
f.write(f"Slot.{slot}: {port_guid}\n")
|
||||
if port_guid != "Not Found":
|
||||
hex_guid_list.append(f"0x{port_guid.replace(':', '').upper()}")
|
||||
|
||||
f.write(f"GUID: {';'.join(hex_guid_list)}\n")
|
||||
```
|
||||
|
||||
## 📊 전체 동작 흐름
|
||||
|
||||
### 시나리오 1: 엑셀 생성 (모달 사용)
|
||||
|
||||
```
|
||||
1. 서버 리스트 입력
|
||||
↓
|
||||
2. "GUID to Excel" 버튼 클릭
|
||||
↓
|
||||
3. 모달창 표시 (저장된 순서 or 기본 순서)
|
||||
↓
|
||||
4. 드래그 앤 드롭으로 순서 변경
|
||||
↓
|
||||
5. "확인 및 엑셀 생성" 클릭
|
||||
↓
|
||||
6. 슬롯 순서 → 로컬 스토리지 저장
|
||||
↓
|
||||
7. 슬롯 순서 → 환경변수로 백엔드 전달
|
||||
↓
|
||||
8. GUIDtxtT0Execl.py 실행
|
||||
↓
|
||||
9. 지정된 순서대로 엑셀 컬럼 정렬 ✅
|
||||
↓
|
||||
10. GUID 필드도 동일한 순서로 합쳐짐 ✅
|
||||
```
|
||||
|
||||
### 시나리오 2: IP 처리 (GUID 수집)
|
||||
|
||||
```
|
||||
1. IP 주소 입력 + GUID 스크립트 선택
|
||||
↓
|
||||
2. "처리 시작" 버튼 클릭
|
||||
↓
|
||||
3. 로컬 스토리지에서 슬롯 순서 읽기
|
||||
↓
|
||||
4. 환경변수로 슬롯 순서 전달 (선택사항)
|
||||
↓
|
||||
5. PortGUID.py / PortGUID_v1.py 실행
|
||||
↓
|
||||
6. 슬롯 GUID 수집
|
||||
↓
|
||||
7. 지정된 순서대로 txt 파일에 저장 ✅
|
||||
↓
|
||||
8. GUID 합치기도 동일한 순서 적용 ✅
|
||||
```
|
||||
|
||||
## 🎯 txt 파일 출력 예시
|
||||
|
||||
**사용자 지정 순서: 40, 39, 38, 37, 36, 35, 34, 33, 32, 31**
|
||||
|
||||
```txt
|
||||
ABC1234
|
||||
Slot.40: 00:11:22:33:44:55:66:77
|
||||
Slot.39: 11:22:33:44:55:66:77:88
|
||||
Slot.38: 22:33:44:55:66:77:88:99
|
||||
Slot.37: 33:44:55:66:77:88:99:AA
|
||||
Slot.36: 44:55:66:77:88:99:AA:BB
|
||||
Slot.35: 55:66:77:88:99:AA:BB:CC
|
||||
Slot.34: 66:77:88:99:AA:BB:CC:DD
|
||||
Slot.33: 77:88:99:AA:BB:CC:DD:EE
|
||||
Slot.32: 88:99:AA:BB:CC:DD:EE:FF
|
||||
Slot.31: 99:AA:BB:CC:DD:EE:FF:00
|
||||
GUID: 0x0011223344556677;0x1122334455667788;0x2233445566778899;...
|
||||
```
|
||||
|
||||
## 💡 핵심 기능
|
||||
|
||||
### 1. **일관성 보장**
|
||||
- 모든 GUID 관련 작업에서 동일한 슬롯 순서 적용
|
||||
- txt 파일 생성 → 엑셀 변환까지 순서 유지
|
||||
|
||||
### 2. **사용자 편의성**
|
||||
- 한 번 설정하면 계속 유지 (로컬 스토리지)
|
||||
- 드래그 앤 드롭으로 직관적인 순서 변경
|
||||
- 기본값 복원 버튼으로 쉬운 리셋
|
||||
|
||||
### 3. **유연성**
|
||||
- 환경변수 없으면 기본 순서 사용 (하위 호환)
|
||||
- 슬롯 개수(4개/10개)에 따라 자동 대응
|
||||
- 모든 GUID 스크립트에서 일관되게 동작
|
||||
|
||||
### 4. **확장성**
|
||||
- 새로운 GUID 스크립트 추가 시 환경변수만 읽으면 됨
|
||||
- 프리셋 기능 추가 가능 (향후)
|
||||
- API 엔드포인트로 확장 가능
|
||||
|
||||
## 🔍 기술적 특징
|
||||
|
||||
### Frontend
|
||||
- **SortableJS**: 드래그 앤 드롭 UI
|
||||
- **LocalStorage**: 브라우저 저장소 활용
|
||||
- **Bootstrap Modal**: 모달 UI
|
||||
- **JavaScript**: 순서 관리 및 전송
|
||||
|
||||
### Backend
|
||||
- **환경변수**: 프로세스 간 데이터 전달
|
||||
- **subprocess**: 스크립트 실행 시 env 전달
|
||||
- **Flask**: 라우팅 및 폼 처리
|
||||
|
||||
### 데이터
|
||||
- **OrderedDict**: Python에서 순서 유지
|
||||
- **Pandas**: 엑셀 생성 시 컬럼 순서 적용
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **환경변수 우선순위**:
|
||||
- 환경변수 있음 → 사용자 지정 순서 적용
|
||||
- 환경변수 없음 → 기본 순서 또는 발견된 순서
|
||||
|
||||
2. **로컬 스토리지 제한**:
|
||||
- 브라우저별로 독립적 (Chrome/Edge 간 공유 안 됨)
|
||||
- 브라우저 캐시 삭제 시 초기화
|
||||
|
||||
3. **스크립트 호환성**:
|
||||
- `PortGUID.py`, `PortGUID_v1.py` 모두 지원
|
||||
- 다른 GUID 스크립트 추가 시 환경변수 읽기 로직 필요
|
||||
|
||||
## 🚀 향후 개선 방안
|
||||
|
||||
1. **서버 저장**: 로컬 스토리지 대신 DB에 저장하여 여러 브라우저에서 공유
|
||||
2. **프리셋 기능**: 자주 사용하는 순서를 프리셋으로 저장
|
||||
3. **자동 감지**: txt 파일에서 실제 존재하는 슬롯만 표시
|
||||
4. **일괄 적용**: IP 처리 시에도 모달에서 순서 설정 가능
|
||||
5. **검증 강화**: 슬롯 번호 유효성 검사 및 중복 체크
|
||||
|
||||
## 📝 관련 파일
|
||||
|
||||
### Frontend
|
||||
- `backend/templates/index.html` - 모달 UI 및 JavaScript
|
||||
|
||||
### Backend
|
||||
- `backend/routes/utilities.py` - 환경변수 전달
|
||||
- `backend/routes/main.py` - IP 처리 라우트
|
||||
|
||||
### Scripts
|
||||
- `data/scripts/PortGUID.py` - GUID 수집 (기본)
|
||||
- `data/scripts/PortGUID_v1.py` - GUID 수집 (v1)
|
||||
- `data/server_list/GUIDtxtT0Execl.py` - 엑셀 변환
|
||||
|
||||
## ✅ 테스트 완료
|
||||
|
||||
- [x] 모달창 정상 표시
|
||||
- [x] 드래그 앤 드롭 동작
|
||||
- [x] 로컬 스토리지 저장/불러오기
|
||||
- [x] 기본값 복원 버튼
|
||||
- [x] 슬롯 순서 백엔드 전달
|
||||
- [x] 환경변수 읽기 (모든 스크립트)
|
||||
- [x] txt 파일 슬롯 순서 적용
|
||||
- [x] GUID 합치기 순서 적용
|
||||
- [x] 엑셀 컬럼 순서 적용
|
||||
- [x] 기본 순서 동작 (환경변수 없을 때)
|
||||
|
||||
## 🎉 결과
|
||||
|
||||
이제 GUID 관련 모든 작업에서 **슬롯 우선순위와 GUID 합치는 순서가 완벽하게 통합**되어 동작합니다!
|
||||
|
||||
사용자가 모달에서 한 번 설정하면:
|
||||
1. ✅ 로컬 스토리지에 저장되어 다음에도 유지
|
||||
2. ✅ txt 파일 생성 시 해당 순서로 저장
|
||||
3. ✅ GUID 합치기도 동일한 순서 적용
|
||||
4. ✅ 엑셀 변환 시 컬럼 순서도 일치
|
||||
|
||||
**완벽한 일관성과 사용자 편의성을 모두 확보했습니다!** 🚀
|
||||
@@ -1,146 +0,0 @@
|
||||
# GUID 엑셀 변환 시 슬롯 우선순위 설정 기능 구현
|
||||
|
||||
## 📋 개요
|
||||
|
||||
GUID to Excel 버튼 클릭 시 슬롯 우선순위(slot 31~40)를 사용자가 직접 지정할 수 있는 모달창을 추가하여, 지정된 순서대로 엑셀 파일에 저장되도록 기능을 구현했습니다.
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
### 1. **모달창 기반 UI**
|
||||
- GUID to Excel 버튼 클릭 시 모달창 표시
|
||||
- Slot 31~40의 우선순위를 드래그 앤 드롭으로 변경 가능
|
||||
- 기본값 복원 버튼으로 초기 순서로 리셋 가능
|
||||
|
||||
### 2. **드래그 앤 드롭 인터페이스**
|
||||
- **SortableJS** 라이브러리 사용
|
||||
- 직관적인 슬롯 순서 조정
|
||||
- 실시간으로 순위 번호 업데이트
|
||||
|
||||
### 3. **백엔드 처리**
|
||||
- 사용자가 지정한 슬롯 순서를 환경변수로 전달
|
||||
- `GUIDtxtT0Execl.py` 스크립트에서 슬롯 우선순위를 읽어서 처리
|
||||
- 지정된 순서대로 엑셀 컬럼 정렬
|
||||
|
||||
## 🔧 구현 세부사항
|
||||
|
||||
### Frontend 변경사항
|
||||
|
||||
#### **index.html**
|
||||
|
||||
**모달창 추가:**
|
||||
```html
|
||||
<div class="modal fade" id="slotPriorityModal">
|
||||
<!-- 슬롯 우선순위 설정 UI -->
|
||||
<ul id="slotList" class="list-group">
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</ul>
|
||||
<form id="slotPriorityForm" action="/update_guid_list" method="post">
|
||||
<input type="hidden" name="slot_priority" id="slot_priority_input">
|
||||
<!-- 서버 리스트 내용도 함께 전달 -->
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
**JavaScript 로직:**
|
||||
- `SortableJS` 라이브러리를 사용하여 드래그 앤 드롭 구현
|
||||
- 기본 슬롯 순서: `['38', '39', '37', '36', '32', '33', '34', '35', '31', '40']`
|
||||
- 폼 제출 시 슬롯 순서를 콤마로 구분된 문자열로 전달
|
||||
|
||||
### Backend 변경사항
|
||||
|
||||
#### **utilities.py**
|
||||
|
||||
```python
|
||||
@utils_bp.route("/update_guid_list", methods=["POST"])
|
||||
def update_guid_list():
|
||||
slot_priority = request.form.get("slot_priority", "")
|
||||
|
||||
# 환경변수로 슬롯 우선순위 전달
|
||||
env = os.environ.copy()
|
||||
if slot_priority:
|
||||
env["GUID_SLOT_PRIORITY"] = slot_priority
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "GUIDtxtT0Execl.py"],
|
||||
env=env, # 환경변수 전달
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
#### **GUIDtxtT0Execl.py**
|
||||
|
||||
```python
|
||||
# 환경변수에서 슬롯 우선순위 읽기
|
||||
slot_priority_str = os.getenv("GUID_SLOT_PRIORITY", "")
|
||||
if slot_priority_str:
|
||||
SLOT_PRIORITY = [s.strip() for s in slot_priority_str.split(",")]
|
||||
else:
|
||||
# 기본 우선순위
|
||||
SLOT_PRIORITY = ['38', '39', '37', '36', '32', '33', '34', '35', '31', '40']
|
||||
|
||||
# 데이터 재정렬
|
||||
for slot_num in SLOT_PRIORITY:
|
||||
slot_key = f"Slot.{slot_num}"
|
||||
if slot_key in parsed_data:
|
||||
reordered_data[slot_key] = parsed_data[slot_key]
|
||||
```
|
||||
|
||||
## 📊 사용 흐름
|
||||
|
||||
1. **서버 리스트 입력**: 텍스트 영역에 서버 목록 입력
|
||||
2. **GUID to Excel 버튼 클릭**: 모달창 표시
|
||||
3. **슬롯 우선순위 설정**: 드래그 앤 드롭으로 순서 변경
|
||||
4. **확인 버튼 클릭**: 설정된 순서로 엑셀 생성
|
||||
5. **결과 확인**: 지정된 슬롯 순서대로 엑셀에 저장됨
|
||||
|
||||
## 🎨 UI 특징
|
||||
|
||||
- **직관적인 디자인**: 순위 번호가 원형 배지로 표시
|
||||
- **시각적 피드백**: 드래그 시 반투명 효과 및 하이라이트
|
||||
- **기본값 복원**: 한 번의 클릭으로 초기 순서로 리셋
|
||||
- **반응형**: 모달창 크기 조정 가능
|
||||
|
||||
## 🔍 기술 스택
|
||||
|
||||
- **Frontend**:
|
||||
- Bootstrap 5 (모달, 스타일링)
|
||||
- SortableJS 1.15.0 (드래그 앤 드롭)
|
||||
- Vanilla JavaScript (로직 처리)
|
||||
|
||||
- **Backend**:
|
||||
- Flask (라우팅)
|
||||
- Python subprocess (스크립트 실행)
|
||||
- 환경변수 (슬롯 순서 전달)
|
||||
|
||||
- **데이터 처리**:
|
||||
- Pandas (엑셀 생성)
|
||||
- OrderedDict (순서 유지)
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **서버 리스트 필수**: 모달 열기 전에 서버 리스트를 입력해야 함
|
||||
2. **슬롯 개수**: 현재는 10개 슬롯(31~40) 기준으로 구현
|
||||
3. **호환성**: 기존 *.txt 파일 형식과 호환됨
|
||||
|
||||
## 🚀 향후 개선 방안
|
||||
|
||||
1. **동적 슬롯 개수**: txt 파일에서 실제 존재하는 슬롯만 표시
|
||||
2. **프리셋 저장**: 자주 사용하는 슬롯 순서를 프리셋으로 저장
|
||||
3. **일괄 적용**: 여러 서버에 동일한 슬롯 순서 적용
|
||||
4. **검증 강화**: 슬롯 번호 유효성 검사 추가
|
||||
|
||||
## ✅ 테스트 시나리오
|
||||
|
||||
1. ✓ 모달창 정상 표시
|
||||
2. ✓ 드래그 앤 드롭 동작 확인
|
||||
3. ✓ 기본값 복원 버튼 동작
|
||||
4. ✓ 슬롯 순서가 백엔드로 정상 전달
|
||||
5. ✓ 엑셀 파일에 순서대로 저장
|
||||
6. ✓ 환경변수 전달 확인
|
||||
7. ✓ 기본 순서 동작 (슬롯 우선순위 미지정 시)
|
||||
|
||||
## 📝 관련 파일
|
||||
|
||||
- `backend/templates/index.html` - 모달 UI 및 JavaScript
|
||||
- `backend/routes/utilities.py` - 백엔드 라우트 처리
|
||||
- `data/server_list/GUIDtxtT0Execl.py` - 엑셀 생성 로직
|
||||
@@ -1,241 +0,0 @@
|
||||
# 슬롯 설정 완전 단순화 (최종)
|
||||
|
||||
## 🎉 드래그 앤 드롭 제거!
|
||||
|
||||
복잡한 드래그 앤 드롭 대신 **텍스트 입력만**으로 슬롯을 설정할 수 있게 완전히 단순화했습니다!
|
||||
|
||||
## ✨ 새로운 간단한 방식
|
||||
|
||||
### 1문. **텍스트 입력 필드**
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 슬롯 번호: [3개] │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 38, 39, 37 │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ 💡 팁: 위에서부터 순서대로 우선순위 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. **빠른 프리셋 버튼**
|
||||
```
|
||||
[2슬롯] [4슬롯] [10슬롯]
|
||||
```
|
||||
클릭하면 자동으로 입력 필드 채워짐!
|
||||
|
||||
### 3. **실시간 미리보기**
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 미리보기: │
|
||||
│ [1] Slot 38 [2] Slot 39 [3] Slot 37│
|
||||
│ 💡 순서: 38 → 39 → 37 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🚀 사용 방법
|
||||
|
||||
### 방법 1: 빠른 프리셋 사용
|
||||
```
|
||||
1. [2슬롯] 또는 [4슬롯] 또는 [10슬롯] 클릭
|
||||
↓
|
||||
2. 입력 필드에 자동으로 슬롯 번호 채워짐
|
||||
↓
|
||||
3. 필요하면 직접 수정
|
||||
↓
|
||||
4. [확인] 클릭
|
||||
```
|
||||
|
||||
### 방법 2: 직접 입력
|
||||
```
|
||||
1. 입력 필드에 슬롯 번호 입력
|
||||
예: 38, 39, 37
|
||||
|
||||
2. 실시간 미리보기 확인
|
||||
|
||||
3. 순서 변경하고 싶으면 입력 필드에서 직접 수정
|
||||
예: 37, 38, 39 (순서 변경)
|
||||
|
||||
4. [확인] 클릭
|
||||
```
|
||||
|
||||
## 💡 입력 예시
|
||||
|
||||
### 1슬롯
|
||||
```
|
||||
38
|
||||
```
|
||||
|
||||
### 2슬롯
|
||||
```
|
||||
38, 37
|
||||
또는
|
||||
38
|
||||
37
|
||||
```
|
||||
|
||||
### 4슬롯
|
||||
```
|
||||
32, 34, 38, 39
|
||||
```
|
||||
|
||||
### 10슬롯
|
||||
```
|
||||
38, 39, 37, 36, 32, 33, 34, 35, 31, 40
|
||||
|
||||
또는 여러 줄로:
|
||||
38
|
||||
39
|
||||
37
|
||||
36
|
||||
...
|
||||
```
|
||||
|
||||
### 순서 변경
|
||||
```
|
||||
처음: 38, 37, 32
|
||||
변경: 32, 38, 37 ← 입력 필드에서 직접 수정!
|
||||
```
|
||||
|
||||
## ⚡ 핵심 장점
|
||||
|
||||
### 1. **초간단**
|
||||
- 드래그 필요 없음
|
||||
- 그냥 타이핑하면 끝!
|
||||
- 복사 & 붙여넣기 가능
|
||||
|
||||
### 2. **명확한 순서**
|
||||
- 위에서 아래로 = 우선순위
|
||||
- 눈으로 바로 확인
|
||||
- 수정도 바로 가능
|
||||
|
||||
### 3. **실시간 미리보기**
|
||||
- 입력하면 즉시 미리보기
|
||||
- 개수 자동 표시
|
||||
- 순서 화살표로 표시
|
||||
|
||||
### 4. **자동 저장**
|
||||
- 로컬 스토리지에 자동 저장
|
||||
- 다음에 모달 열면 이전 설정 그대로
|
||||
- 별도 저장 버튼 필요 없음
|
||||
|
||||
### 5. **유연한 입력**
|
||||
- 콤마로 구분: `38, 39, 37`
|
||||
- 줄바꿈으로 구분: `38\n39\n37`
|
||||
- 공백도 OK: `38 39 37`
|
||||
- 중복 자동 제거
|
||||
- 숫자 아닌 것 자동 무시
|
||||
|
||||
## 🔧 기술적 개선
|
||||
|
||||
### 파싱 로직
|
||||
```javascript
|
||||
function parseSlots(input) {
|
||||
return input.split(/[,\s\n]+/) // 콤마, 공백, 줄바꿈 모두 허용
|
||||
.map(s => s.trim())
|
||||
.filter(s => s !== '')
|
||||
.filter(s => /^\d+$/.test(s)) // 숫자만
|
||||
.filter((v, i, a) => a.indexOf(v) === i); // 중복 제거
|
||||
}
|
||||
```
|
||||
|
||||
### 실시간 업데이트
|
||||
```javascript
|
||||
slotNumbersInput.addEventListener('input', updatePreview);
|
||||
```
|
||||
타이핑하자마자 미리보기 업데이트!
|
||||
|
||||
### 자동 저장
|
||||
```javascript
|
||||
function saveToStorage() {
|
||||
localStorage.setItem('guidSlotNumbers', slotNumbersInput.value);
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 비교
|
||||
|
||||
### 이전 (복잡)
|
||||
```
|
||||
❌ 드래그 앤 드롭 필요
|
||||
❌ 그립 아이콘 찾아야 함
|
||||
❌ 손 모양 커서 안 나타남
|
||||
❌ 순서 변경 어려움
|
||||
❌ 모바일에서 불편
|
||||
```
|
||||
|
||||
### 현재 (간단)
|
||||
```
|
||||
✅ 텍스트만 입력
|
||||
✅ 복사 & 붙여넣기
|
||||
✅ 순서는 그냥 타이핑 순서
|
||||
✅ 실시간 미리보기
|
||||
✅ 모바일에서도 편함
|
||||
✅ Excel에서 복사 가능
|
||||
```
|
||||
|
||||
## 🎯 실제 사용 시나리오
|
||||
|
||||
### 시나리오 1: 4슬롯 서버 설정
|
||||
```
|
||||
1. 모달 열기
|
||||
2. 입력: 32, 34, 38, 39
|
||||
3. 미리보기 확인: [1]Slot 32 [2]Sl34 [3]Slot 38 [4]Slot 39
|
||||
4. [확인] 클릭
|
||||
완료!
|
||||
```
|
||||
|
||||
### 시나리오 2: 순서 변경
|
||||
```
|
||||
1. 현재: 38, 39, 37
|
||||
2. 입력 필드에서 직접 수정: 37, 38, 39
|
||||
3. 미리보기: [1]Slot 37 [2]Slot 38 [3]Slot 39
|
||||
4. [확인] 클릭
|
||||
완료!
|
||||
```
|
||||
|
||||
### 시나리오 3: Excel에서 복사
|
||||
```
|
||||
1. Excel에 슬롯 목록 있음:
|
||||
38
|
||||
39
|
||||
37
|
||||
36
|
||||
|
||||
2. 복사 (Ctrl+C)
|
||||
|
||||
3. 모달 입력 필드에 붙여넣기 (Ctrl+V)
|
||||
|
||||
4. 자동 파싱: 38, 39, 37, 36
|
||||
|
||||
완료!
|
||||
```
|
||||
|
||||
## ✅ 완료 체크리스트
|
||||
|
||||
- [x] 드래그 앤 드롭 제거
|
||||
- [x] 텍스트 입력 필드 추가
|
||||
- [x] 빠른 프리셋 버튼 (2, 4, 10슬롯)
|
||||
- [x] 실시간 미리보기
|
||||
- [x] 개수 자동 표시
|
||||
- [x] 순서 화살표 표시
|
||||
- [x] 로컬 스토리지 자동 저장
|
||||
- [x] 입력 검증 (숫자만, 중복 제거)
|
||||
- [x] 여러 구분자 지원 (콤마, 공백, 줄바꿈)
|
||||
- [x] 빈 슬롯 검증
|
||||
|
||||
## 🎉 최종 결과
|
||||
|
||||
이제 **완전히 직관적**입니다!
|
||||
|
||||
```
|
||||
입력 필드에 슬롯 번호 입력
|
||||
↓
|
||||
실시간 미리보기 확인
|
||||
↓
|
||||
[확인] 클릭
|
||||
↓
|
||||
완료! ✅
|
||||
```
|
||||
|
||||
**드래그 걱정 없이, 텍스트만 입력하면 끝!** 🚀
|
||||
|
||||
훨씬 간단하고 확실하고 빠릅니다!
|
||||
@@ -1,915 +0,0 @@
|
||||
# iDRAC Info Manager
|
||||
|
||||
Dell iDRAC 서버 관리 및 펌웨어 업데이트 자동화를 위한 Flask 기반 웹 애플리케이션
|
||||
|
||||
## 📋 목차
|
||||
|
||||
- [소개](#소개)
|
||||
- [주요 기능](#주요-기능)
|
||||
- [기술 스택](#기술-스택)
|
||||
- [시스템 아키텍처](#시스템-아키텍처)
|
||||
- [시작하기](#시작하기)
|
||||
- [프로젝트 구조](#프로젝트-구조)
|
||||
- [사용법](#사용법)
|
||||
- [고급 기능](#고급-기능)
|
||||
- [Git 사용법](#git-사용법)
|
||||
- [문제 해결](#문제-해결)
|
||||
- [라이선스](#라이선스)
|
||||
|
||||
## 📖 소개
|
||||
|
||||
**iDRAC Info Manager**는 Dell iDRAC(Integrated Dell Remote Access Controller) 서버를 효율적으로 관리하고 펌웨어 업데이트를 자동화하기 위한 웹 기반 도구입니다.
|
||||
|
||||
이 애플리케이션은 다음과 같은 시나리오에서 유용합니다:
|
||||
- 여러 대의 Dell 서버를 동시에 관리해야 할 때
|
||||
- 펌웨어 카탈로그를 주기적으로 업데이트하고 관리해야 할 때
|
||||
- iDRAC 설정을 XML 파일로 관리하고 배포해야 할 때
|
||||
- 서버 하드웨어 정보(MAC, GUID, GPU 등)를 수집하고 관리해야 할 때
|
||||
- Telegram을 통해 서버 관리 알림을 받고 싶을 때
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
### 🖥️ iDRAC 서버 관리
|
||||
- **Redfish API 통합**: Dell Redfish API를 통한 서버 정보 조회 및 제어
|
||||
- **다중 IP 처리**: 여러 서버에 대한 일괄 작업 실행
|
||||
- **실시간 모니터링**: SocketIO를 통한 실시간 작업 진행 상황 확인
|
||||
- **작업 큐 관리**: 백그라운드 작업 스케줄링 및 상태 추적
|
||||
|
||||
### 📦 펌웨어 관리
|
||||
- **Dell 카탈로그 동기화**: Dell 공식 펌웨어 카탈로그 자동 다운로드 및 파싱
|
||||
- **DRM 통합**: Dell Repository Manager(DRM) XML 파일 지원
|
||||
- **펌웨어 버전 추적**: 서버별 펌웨어 버전 비교 및 업데이트 필요 여부 확인
|
||||
- **카탈로그 관리**: 여러 펌웨어 카탈로그 버전 관리 및 전환
|
||||
|
||||
### 📄 XML 설정 관리
|
||||
- **XML 파일 업로드**: iDRAC 설정 XML 파일 업로드 및 저장
|
||||
- **설정 편집**: 웹 인터페이스를 통한 XML 설정 편집
|
||||
- **SCP 파일 관리**: Server Configuration Profile 파일 관리
|
||||
- **자동 백업**: 파일 변경 시 자동 백업 생성
|
||||
|
||||
### 📊 데이터 수집 및 변환
|
||||
- **MAC 주소 수집**: 서버 네트워크 인터페이스 MAC 주소 수집
|
||||
- **GUID 추출**: 서버 GUID 정보 추출 및 관리
|
||||
- **GPU 시리얼 수집**: GPU 하드웨어 시리얼 번호 수집
|
||||
- **Excel 변환**: 수집된 데이터를 Excel 파일로 자동 변환
|
||||
- **서버 리스트 관리**: 서버 목록 관리 및 매핑
|
||||
|
||||
### 👥 사용자 관리
|
||||
- **인증 시스템**: Flask-Login 기반 보안 인증
|
||||
- **사용자 승인 워크플로우**: 신규 사용자 등록 시 관리자 승인 필요
|
||||
- **Telegram 승인**: Telegram 봇을 통한 사용자 승인/거부
|
||||
- **비밀번호 암호화**: Argon2 해싱을 통한 안전한 비밀번호 저장
|
||||
- **세션 관리**: 자동 로그아웃 및 세션 타임아웃
|
||||
|
||||
### 📱 Telegram 봇 통합
|
||||
- **실시간 알림**: 로그인, 회원가입, 작업 완료 알림
|
||||
- **인터랙티브 승인**: 인라인 버튼을 통한 사용자 승인/거부
|
||||
- **봇 폴링 서비스**: 백그라운드 스레드에서 실행되는 봇 서비스
|
||||
- **중복 실행 방지**: 봇 인스턴스 중복 실행 방지 메커니즘
|
||||
|
||||
### 🔍 실시간 파일 모니터링
|
||||
- **Watchdog 통합**: 파일 시스템 변경 실시간 감지
|
||||
- **자동 UI 업데이트**: 파일 변경 시 웹 인터페이스 자동 갱신
|
||||
- **이벤트 로깅**: 파일 생성, 수정, 삭제 이벤트 로깅
|
||||
|
||||
## 🛠 기술 스택
|
||||
|
||||
### Backend Framework
|
||||
- **Flask 3.1.2**: 경량 웹 프레임워크
|
||||
- **Flask-SocketIO 5.5.1**: 실시간 양방향 통신
|
||||
- **Flask-Login 0.6.3**: 사용자 인증 및 세션 관리
|
||||
- **Flask-Migrate 4.1.0**: 데이터베이스 마이그레이션
|
||||
- **Flask-WTF 1.2.2**: 폼 처리 및 CSRF 보호
|
||||
|
||||
### Database
|
||||
- **SQLAlchemy 2.0.43**: ORM (Object-Relational Mapping)
|
||||
- **Flask-SQLAlchemy 3.1.1**: Flask-SQLAlchemy 통합
|
||||
- **Alembic 1.16.5**: 데이터베이스 마이그레이션 도구
|
||||
- **SQLite**: 기본 데이터베이스 (운영 환경에서는 PostgreSQL/MySQL 권장)
|
||||
|
||||
### Security
|
||||
- **Argon2-cffi 25.1.0**: 비밀번호 해싱
|
||||
- **Flask-WTF**: CSRF 토큰 보호
|
||||
- **Werkzeug 3.1.3**: 보안 유틸리티
|
||||
|
||||
### Data Processing
|
||||
- **Pandas 2.3.3**: 데이터 분석 및 처리
|
||||
- **NumPy 2.3.3**: 수치 연산
|
||||
- **OpenPyXL 3.1.5**: Excel 파일 생성 및 편집
|
||||
- **Chardet 5.2.0**: 문자 인코딩 자동 감지
|
||||
- **Natsort 8.4.0**: 자연스러운 정렬
|
||||
|
||||
### Real-time Communication
|
||||
- **Python-SocketIO 5.14.1**: SocketIO 서버 구현
|
||||
- **Python-EngineIO 4.12.3**: EngineIO 프로토콜
|
||||
- **Simple-WebSocket 1.1.0**: WebSocket 통신
|
||||
|
||||
### Monitoring & Notifications
|
||||
- **Watchdog 6.0.0**: 파일 시스템 모니터링
|
||||
- **Python-Telegram-Bot 22.5**: Telegram 봇 API
|
||||
|
||||
### HTTP & API
|
||||
- **Requests 2.32.3**: HTTP 클라이언트
|
||||
- **DNSPython 2.8.0**: DNS 조회
|
||||
|
||||
### Frontend
|
||||
- **Bootstrap 5**: 반응형 UI 프레임워크
|
||||
- **Bootstrap Icons**: 아이콘 세트
|
||||
- **Socket.IO Client**: 실시간 통신 클라이언트
|
||||
- **JavaScript (ES6+)**: 클라이언트 사이드 로직
|
||||
|
||||
### Development & Testing
|
||||
- **Pytest 8.0.0**: 테스트 프레임워크
|
||||
- **Pytest-Mock 3.12.0**: 모킹 라이브러리
|
||||
- **Python-Dotenv 1.0.1**: 환경 변수 관리
|
||||
|
||||
## 🏗️ 시스템 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Client Browser │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ HTML/CSS │ │ JavaScript │ │ SocketIO CLI │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↕ HTTP/WebSocket
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Flask Application │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Routes Layer │ │
|
||||
│ │ • auth.py (인증) • main.py (메인) │ │
|
||||
│ │ • admin.py (관리) • api.py (API) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Services Layer │ │
|
||||
│ │ • redfish_client.py (iDRAC 통신) │ │
|
||||
│ │ • firmware_service.py (펌웨어 관리) │ │
|
||||
│ │ • watchdog_handler.py (파일 모니터링) │ │
|
||||
│ │ • logger.py (로깅) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Models Layer │ │
|
||||
│ │ • user.py (사용자) • job.py (작업) │ │
|
||||
│ │ • firmware.py (펌웨어) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↕
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ External Services │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ SQLite DB │ │ Telegram Bot │ │ Dell Redfish │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 주요 컴포넌트
|
||||
|
||||
1. **Routes (라우트)**
|
||||
- 사용자 요청을 받아 적절한 서비스로 전달
|
||||
- 인증, 권한 검사, 입력 검증 수행
|
||||
|
||||
2. **Services (서비스)**
|
||||
- 비즈니스 로직 구현
|
||||
- 외부 API 통신 (Redfish, Telegram)
|
||||
- 파일 처리 및 데이터 변환
|
||||
|
||||
3. **Models (모델)**
|
||||
- 데이터베이스 스키마 정의
|
||||
- ORM을 통한 데이터 접근
|
||||
|
||||
4. **SocketIO Events**
|
||||
- 실시간 양방향 통신
|
||||
- 진행 상황 업데이트
|
||||
- 파일 변경 알림
|
||||
|
||||
## 🚀 시작하기
|
||||
|
||||
### 필수 요구사항
|
||||
|
||||
- **Python 3.8 이상** (Python 3.10+ 권장)
|
||||
- **pip** (Python 패키지 관리자)
|
||||
- **Git** (버전 관리)
|
||||
- **Dell iDRAC 9 이상** (Redfish API 지원)
|
||||
|
||||
### 설치 방법
|
||||
|
||||
#### Windows
|
||||
|
||||
```powershell
|
||||
# 1. 저장소 클론
|
||||
git clone https://github.com/yourusername/idrac_info.git
|
||||
cd idrac_info
|
||||
|
||||
# 2. 가상환경 생성 및 활성화
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
|
||||
# 3. 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. 환경 변수 설정 (.env 파일 생성)
|
||||
Copy-Item .env.example .env
|
||||
# .env 파일을 편집하여 필요한 값 설정
|
||||
|
||||
# 5. 데이터베이스 초기화
|
||||
flask db upgrade
|
||||
|
||||
# 6. 애플리케이션 실행
|
||||
python app.py
|
||||
```
|
||||
|
||||
#### Linux / macOS
|
||||
|
||||
```bash
|
||||
# 1. 저장소 클론
|
||||
git clone https://github.com/yourusername/idrac_info.git
|
||||
cd idrac_info
|
||||
|
||||
# 2. 가상환경 생성 및 활성화
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# 3. 의존성 설치
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. 환경 변수 설정 (.env 파일 생성)
|
||||
cp .env.example .env
|
||||
# .env 파일을 편집하여 필요한 값 설정
|
||||
|
||||
# 5. 데이터베이스 초기화
|
||||
flask db upgrade
|
||||
|
||||
# 6. 애플리케이션 실행
|
||||
python app.py
|
||||
```
|
||||
|
||||
### 환경 설정
|
||||
|
||||
`.env` 파일을 생성하고 다음 내용을 설정하세요:
|
||||
|
||||
```env
|
||||
# ========================================
|
||||
# Flask 기본 설정
|
||||
# ========================================
|
||||
SECRET_KEY=your-super-secret-key-change-this-in-production
|
||||
FLASK_ENV=development
|
||||
FLASK_DEBUG=true
|
||||
FLASK_HOST=0.0.0.0
|
||||
FLASK_PORT=5000
|
||||
|
||||
# ========================================
|
||||
# 데이터베이스 설정
|
||||
# ========================================
|
||||
# SQLite (개발용)
|
||||
DATABASE_URL=sqlite:///backend/instance/site.db
|
||||
|
||||
# PostgreSQL (운영용 - 권장)
|
||||
# DATABASE_URL=postgresql://username:password@localhost:5432/idrac_info
|
||||
|
||||
# MySQL (운영용)
|
||||
# DATABASE_URL=mysql://username:password@localhost:3306/idrac_info
|
||||
|
||||
# ========================================
|
||||
# Redfish API 설정
|
||||
# ========================================
|
||||
REDFISH_TIMEOUT=15
|
||||
REDFISH_VERIFY_SSL=false
|
||||
|
||||
# ========================================
|
||||
# Telegram Bot 설정 (선택 사항)
|
||||
# ========================================
|
||||
# Telegram BotFather에서 봇 생성 후 토큰 입력
|
||||
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||
# Telegram에서 본인의 Chat ID 입력
|
||||
TELEGRAM_CHAT_ID=123456789
|
||||
|
||||
# ========================================
|
||||
# 데이터 디렉토리 설정
|
||||
# ========================================
|
||||
APP_DATA_DIR=./data
|
||||
|
||||
# ========================================
|
||||
# 업로드 및 파일 설정
|
||||
# ========================================
|
||||
MAX_CONTENT_LENGTH=10485760 # 10MB
|
||||
FILES_PER_PAGE=35
|
||||
BACKUP_FILES_PER_PAGE=3
|
||||
|
||||
# ========================================
|
||||
# 작업 처리 설정
|
||||
# ========================================
|
||||
MAX_WORKERS=60 # 동시 처리 가능한 최대 작업 수
|
||||
|
||||
# ========================================
|
||||
# 세션 설정
|
||||
# ========================================
|
||||
SESSION_MINUTES=30 # 세션 타임아웃 (분)
|
||||
|
||||
# ========================================
|
||||
# SocketIO 설정
|
||||
# ========================================
|
||||
# threading (Windows 권장) / eventlet (Linux 권장)
|
||||
SOCKETIO_ASYNC_MODE=threading
|
||||
|
||||
# ========================================
|
||||
# 데이터베이스 자동 부트스트랩 (개발용만)
|
||||
# ========================================
|
||||
AUTO_BOOTSTRAP_DB=false
|
||||
```
|
||||
|
||||
### 초기 관리자 계정 생성
|
||||
|
||||
```bash
|
||||
# Flask shell 실행
|
||||
flask shell
|
||||
|
||||
# Python 인터프리터에서 실행
|
||||
>>> from backend.models.user import db, User
|
||||
>>> admin = User(username='admin', email='admin@example.com', is_admin=True, is_approved=True)
|
||||
>>> admin.set_password('your-admin-password')
|
||||
>>> db.session.add(admin)
|
||||
>>> db.session.commit()
|
||||
>>> exit()
|
||||
```
|
||||
|
||||
### 실행 방법
|
||||
|
||||
```bash
|
||||
# 개발 서버 실행
|
||||
python app.py
|
||||
|
||||
# 또는 Flask CLI 사용
|
||||
flask run
|
||||
|
||||
# 특정 호스트/포트 지정
|
||||
flask run --host=0.0.0.0 --port=8080
|
||||
```
|
||||
|
||||
브라우저에서 `http://localhost:5000` 접속
|
||||
|
||||
## 📁 프로젝트 구조
|
||||
|
||||
```
|
||||
idrac_info/
|
||||
├── app.py # 메인 애플리케이션 엔트리포인트
|
||||
├── config.py # 애플리케이션 설정
|
||||
├── requirements.txt # Python 의존성 목록
|
||||
├── telegram_bot_service.py # Telegram 봇 폴링 서비스
|
||||
├── migrate_passwords.py # 비밀번호 마이그레이션 스크립트
|
||||
├── update_db.py # 데이터베이스 업데이트 스크립트
|
||||
├── update_user_approval.py # 사용자 승인 상태 업데이트 스크립트
|
||||
├── check_telegram.py # Telegram 설정 확인 스크립트
|
||||
│
|
||||
├── .env # 환경 변수 (git에서 제외)
|
||||
├── .gitignore # Git 제외 파일 목록
|
||||
│
|
||||
├── backend/ # 백엔드 코드
|
||||
│ ├── __init__.py
|
||||
│ │
|
||||
│ ├── models/ # 데이터베이스 모델
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── user.py # 사용자 모델
|
||||
│ │ ├── job.py # 작업 모델
|
||||
│ │ ├── firmware.py # 펌웨어 모델
|
||||
│ │ └── telegram_bot.py # Telegram 봇 모델
|
||||
│ │
|
||||
│ ├── routes/ # 라우트 핸들러
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── main.py # 메인 페이지 라우트
|
||||
│ │ ├── auth.py # 인증 라우트
|
||||
│ │ ├── admin.py # 관리자 라우트
|
||||
│ │ ├── api.py # API 엔드포인트
|
||||
│ │ ├── firmware.py # 펌웨어 관리 라우트
|
||||
│ │ └── xml_routes.py # XML 파일 관리 라우트
|
||||
│ │
|
||||
│ ├── services/ # 비즈니스 로직
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── redfish_client.py # Redfish API 클라이언트
|
||||
│ │ ├── firmware_service.py # 펌웨어 관리 서비스
|
||||
│ │ ├── drm_parser.py # DRM XML 파서
|
||||
│ │ ├── watchdog_handler.py # 파일 모니터링
|
||||
│ │ ├── logger.py # 로깅 설정
|
||||
│ │ ├── platform_utils.py # 플랫폼 유틸리티
|
||||
│ │ └── file_utils.py # 파일 유틸리티
|
||||
│ │
|
||||
│ ├── forms/ # WTForms 폼
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── auth_forms.py # 인증 폼
|
||||
│ │ └── admin_forms.py # 관리자 폼
|
||||
│ │
|
||||
│ ├── templates/ # Jinja2 템플릿
|
||||
│ │ ├── base.html # 기본 레이아웃
|
||||
│ │ ├── index.html # 메인 페이지
|
||||
│ │ ├── login.html # 로그인 페이지
|
||||
│ │ ├── register.html # 회원가입 페이지
|
||||
│ │ ├── admin.html # 관리자 페이지
|
||||
│ │ ├── admin_settings.html # 관리자 설정
|
||||
│ │ ├── jobs.html # 작업 관리
|
||||
│ │ ├── idrac_firmware.html # 펌웨어 관리
|
||||
│ │ ├── xml_files.html # XML 파일 목록
|
||||
│ │ └── edit_xml.html # XML 편집기
|
||||
│ │
|
||||
│ ├── static/ # 정적 파일
|
||||
│ │ ├── css/
|
||||
│ │ │ ├── admin.css
|
||||
│ │ │ ├── admin_settings.css
|
||||
│ │ │ ├── edit_xml.css
|
||||
│ │ │ ├── index.css
|
||||
│ │ │ └── jobs.css
|
||||
│ │ ├── js/
|
||||
│ │ │ ├── admin.js
|
||||
│ │ │ ├── admin_settings.js
|
||||
│ │ │ ├── edit_xml.js
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ └── jobs.js
|
||||
│ │ └── images/
|
||||
│ │
|
||||
│ ├── instance/ # 인스턴스별 데이터
|
||||
│ │ └── site.db # SQLite 데이터베이스
|
||||
│ │
|
||||
│ └── socketio_events.py # SocketIO 이벤트 핸들러
|
||||
│
|
||||
├── data/ # 애플리케이션 데이터
|
||||
│ ├── logs/ # 로그 파일
|
||||
│ ├── uploads/ # 업로드된 파일
|
||||
│ ├── temp_ip/ # 임시 IP 파일
|
||||
│ ├── temp_zips/ # 임시 ZIP 파일
|
||||
│ ├── xml/ # XML 설정 파일
|
||||
│ ├── scripts/ # 스크립트 파일
|
||||
│ ├── idrac_info/ # iDRAC 정보 파일
|
||||
│ ├── mac/ # MAC 주소 파일
|
||||
│ ├── guid_file/ # GUID 파일
|
||||
│ ├── gpu_serial/ # GPU 시리얼 파일
|
||||
│ ├── mac_backup/ # MAC 백업 파일
|
||||
│ └── server_list/ # 서버 리스트 파일
|
||||
│
|
||||
├── migrations/ # Alembic 마이그레이션
|
||||
│ ├── versions/ # 마이그레이션 버전
|
||||
│ ├── alembic.ini
|
||||
│ ├── env.py
|
||||
│ └── README
|
||||
│
|
||||
├── tests/ # 테스트 코드
|
||||
│ ├── __init__.py
|
||||
│ ├── test_auth.py
|
||||
│ ├── test_api.py
|
||||
│ └── test_services.py
|
||||
│
|
||||
└── venv/ # 가상환경 (git에서 제외)
|
||||
```
|
||||
|
||||
## 💡 사용법
|
||||
|
||||
### 1. 로그인 및 회원가입
|
||||
|
||||
1. 브라우저에서 `http://localhost:5000` 접속
|
||||
2. 회원가입 페이지에서 계정 생성
|
||||
3. 관리자 승인 대기 (Telegram 봇 설정 시 자동 알림)
|
||||
4. 승인 후 로그인
|
||||
|
||||
### 2. iDRAC 서버 정보 수집
|
||||
|
||||
1. 메인 페이지에서 "IP 처리" 섹션 선택
|
||||
2. 스크립트 선택 (예: `get_idrac_info.py`)
|
||||
3. IP 주소 입력 (각 줄에 하나씩)
|
||||
```
|
||||
192.168.1.100
|
||||
192.168.1.101
|
||||
192.168.1.102
|
||||
```
|
||||
4. iDRAC 사용자명/비밀번호 입력
|
||||
5. "처리" 버튼 클릭
|
||||
6. 실시간 진행 상황 확인
|
||||
7. 완료 후 결과 파일 다운로드
|
||||
|
||||
### 3. 펌웨어 카탈로그 관리
|
||||
|
||||
1. 관리자 페이지 접속
|
||||
2. "펌웨어 관리" 섹션 선택
|
||||
3. "Dell 카탈로그 동기화" 버튼 클릭
|
||||
4. 카탈로그 다운로드 및 파싱 대기
|
||||
5. 펌웨어 목록 확인
|
||||
|
||||
### 4. DRM XML 파일 업로드
|
||||
|
||||
1. Dell Repository Manager에서 XML 카탈로그 생성
|
||||
2. 관리자 설정 페이지 접속
|
||||
3. "DRM Repository 상태 확인" 클릭
|
||||
4. XML 파일 업로드
|
||||
5. "DRM 데이터 동기화" 클릭
|
||||
|
||||
### 5. XML 설정 파일 관리
|
||||
|
||||
1. "XML 파일" 메뉴 선택
|
||||
2. XML 파일 업로드 또는 기존 파일 선택
|
||||
3. "편집" 버튼 클릭하여 웹 에디터에서 수정
|
||||
4. 저장 시 자동 백업 생성
|
||||
5. SCP 파일 다운로드 또는 서버에 적용
|
||||
|
||||
### 6. MAC/GUID 데이터 수집 및 변환
|
||||
|
||||
#### MAC 주소 수집
|
||||
1. "MAC to Excel" 섹션 선택
|
||||
2. MAC 주소 데이터 입력 또는 파일 업로드
|
||||
3. 서버 리스트 입력 (선택 사항)
|
||||
4. "변환" 버튼 클릭
|
||||
5. 생성된 Excel 파일 다운로드
|
||||
|
||||
#### GUID 수집
|
||||
1. "GUID to Excel" 섹션 선택
|
||||
2. GUID 데이터 입력
|
||||
3. 서버 리스트 매핑
|
||||
4. Excel 파일 생성 및 다운로드
|
||||
523:
|
||||
524: #### GUID 수집 및 슬롯 우선순위 설정
|
||||
525: 1. "GUID to Excel" 섹션 선택
|
||||
526: 2. "슬롯 설정" 모달 창 열기
|
||||
527: 3. 원하는 슬롯 순서 입력 또는 프리셋 버튼(2슬롯, 4슬롯, 10슬롯) 사용
|
||||
528: - 예: `38, 37` (2슬롯), `38, 37, 32, 34` (4슬롯)
|
||||
529: - 입력된 순서대로 엑셀 컬럼이 정렬됩니다.
|
||||
530: 4. GUID 데이터 입력 및 확인 버튼 클릭
|
||||
531: 5. Excel 파일 생성 및 다운로드
|
||||
|
||||
### 7. 작업 모니터링
|
||||
|
||||
1. "작업 관리" 메뉴 선택
|
||||
2. 실행 중인 작업 목록 확인
|
||||
3. 작업 상세 정보 보기
|
||||
4. 필요시 작업 취소
|
||||
|
||||
## 🔧 고급 기능
|
||||
|
||||
### Telegram 봇 설정
|
||||
|
||||
#### 1. Telegram 봇 생성
|
||||
|
||||
1. Telegram에서 [@BotFather](https://t.me/botfather) 검색
|
||||
2. `/newbot` 명령어 입력
|
||||
3. 봇 이름 및 사용자명 설정
|
||||
4. 발급받은 토큰을 `.env` 파일의 `TELEGRAM_BOT_TOKEN`에 입력
|
||||
|
||||
#### 2. Chat ID 확인
|
||||
|
||||
1. [@userinfobot](https://t.me/userinfobot) 검색
|
||||
2. 봇과 대화 시작
|
||||
3. 표시되는 ID를 `.env` 파일의 `TELEGRAM_CHAT_ID`에 입력
|
||||
|
||||
#### 3. 봇 기능 활성화
|
||||
|
||||
- 애플리케이션 재시작 시 자동으로 봇 폴링 시작
|
||||
- 사용자 로그인/회원가입 시 Telegram 알림 전송
|
||||
- 신규 사용자 승인 요청 시 인라인 버튼으로 승인/거부 가능
|
||||
|
||||
### 데이터베이스 마이그레이션
|
||||
|
||||
#### 새로운 마이그레이션 생성
|
||||
|
||||
```bash
|
||||
# 모델 변경 후 마이그레이션 파일 생성
|
||||
flask db migrate -m "Add new column to user table"
|
||||
|
||||
# 마이그레이션 적용
|
||||
flask db upgrade
|
||||
|
||||
# 마이그레이션 롤백
|
||||
flask db downgrade
|
||||
```
|
||||
|
||||
#### 기존 데이터베이스 업그레이드
|
||||
|
||||
```bash
|
||||
# 현재 마이그레이션 상태 확인
|
||||
flask db current
|
||||
|
||||
# 최신 버전으로 업그레이드
|
||||
flask db upgrade
|
||||
|
||||
# 특정 버전으로 업그레이드
|
||||
flask db upgrade <revision>
|
||||
```
|
||||
|
||||
### 비밀번호 해싱 마이그레이션
|
||||
|
||||
기존 사용자의 비밀번호를 Argon2로 마이그레이션:
|
||||
|
||||
```bash
|
||||
python migrate_passwords.py
|
||||
```
|
||||
|
||||
### 사용자 승인 상태 업데이트
|
||||
|
||||
기존 사용자를 일괄 승인:
|
||||
|
||||
```bash
|
||||
python update_user_approval.py
|
||||
```
|
||||
|
||||
### 로그 관리
|
||||
|
||||
로그 파일 위치: `data/logs/app.log`
|
||||
|
||||
로그 레벨 설정 (`.env` 파일):
|
||||
```env
|
||||
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
```
|
||||
|
||||
로그 파일 로테이션:
|
||||
- 자동으로 날짜별 로그 파일 생성
|
||||
- 최대 30일간 보관
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
#### 동시 작업 수 조정
|
||||
|
||||
`.env` 파일에서 `MAX_WORKERS` 값 조정:
|
||||
```env
|
||||
MAX_WORKERS=60 # CPU 코어 수에 따라 조정
|
||||
```
|
||||
|
||||
#### SocketIO 모드 변경
|
||||
|
||||
Windows:
|
||||
```env
|
||||
SOCKETIO_ASYNC_MODE=threading
|
||||
```
|
||||
|
||||
Linux (eventlet 설치 필요):
|
||||
```env
|
||||
SOCKETIO_ASYNC_MODE=eventlet
|
||||
```
|
||||
|
||||
```bash
|
||||
pip install eventlet
|
||||
```
|
||||
|
||||
### 운영 환경 배포
|
||||
|
||||
#### Gunicorn 사용 (Linux)
|
||||
|
||||
```bash
|
||||
# Gunicorn 설치
|
||||
pip install gunicorn eventlet
|
||||
|
||||
# 실행
|
||||
gunicorn --worker-class eventlet -w 1 --bind 0.0.0.0:5000 app:app
|
||||
```
|
||||
|
||||
#### Nginx 리버스 프록시 설정
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /socket.io {
|
||||
proxy_pass http://127.0.0.1:5000/socket.io;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Systemd 서비스 등록 (Linux)
|
||||
|
||||
`/etc/systemd/system/idrac-info.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=iDRAC Info Manager
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
WorkingDirectory=/path/to/idrac_info
|
||||
Environment="PATH=/path/to/idrac_info/venv/bin"
|
||||
ExecStart=/path/to/idrac_info/venv/bin/gunicorn --worker-class eventlet -w 1 --bind 127.0.0.1:5000 app:app
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
서비스 시작:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable idrac-info
|
||||
sudo systemctl start idrac-info
|
||||
sudo systemctl status idrac-info
|
||||
```
|
||||
|
||||
## 📚 Git 사용법
|
||||
|
||||
### 변경사항 커밋 및 푸시
|
||||
|
||||
```bash
|
||||
# 변경된 파일 확인
|
||||
git status
|
||||
|
||||
# 모든 변경사항 스테이징
|
||||
git add -A
|
||||
|
||||
# 또는 특정 파일만 스테이징
|
||||
git add backend/routes/main.py
|
||||
|
||||
# 커밋 메시지와 함께 커밋
|
||||
git commit -m "Add new feature: firmware auto-update"
|
||||
|
||||
# 원격 저장소에 푸시
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### 원격 저장소에서 최신 버전 가져오기
|
||||
|
||||
```bash
|
||||
# 최신 변경사항 가져오기
|
||||
git pull origin main
|
||||
|
||||
# 또는 fetch 후 merge
|
||||
git fetch origin
|
||||
git merge origin/main
|
||||
```
|
||||
|
||||
### 브랜치 관리
|
||||
|
||||
```bash
|
||||
# 새 브랜치 생성
|
||||
git checkout -b feature/new-feature
|
||||
|
||||
# 브랜치 전환
|
||||
git checkout main
|
||||
|
||||
# 브랜치 목록 확인
|
||||
git branch -a
|
||||
|
||||
# 브랜치 병합
|
||||
git checkout main
|
||||
git merge feature/new-feature
|
||||
|
||||
# 브랜치 삭제
|
||||
git branch -d feature/new-feature
|
||||
```
|
||||
|
||||
### 원격 저장소 확인
|
||||
|
||||
```bash
|
||||
# 원격 저장소 목록
|
||||
git remote -v
|
||||
|
||||
# 원격 저장소 추가
|
||||
git remote add origin https://github.com/username/idrac_info.git
|
||||
|
||||
# 원격 저장소 URL 변경
|
||||
git remote set-url origin https://github.com/username/new-repo.git
|
||||
```
|
||||
|
||||
### 변경사항 되돌리기
|
||||
|
||||
```bash
|
||||
# 작업 디렉토리 변경사항 취소
|
||||
git checkout -- filename
|
||||
|
||||
# 스테이징 취소
|
||||
git reset HEAD filename
|
||||
|
||||
# 마지막 커밋 취소 (변경사항 유지)
|
||||
git reset --soft HEAD~1
|
||||
|
||||
# 마지막 커밋 취소 (변경사항 삭제)
|
||||
git reset --hard HEAD~1
|
||||
```
|
||||
|
||||
## 🐛 문제 해결
|
||||
|
||||
### Telegram 봇이 응답하지 않음
|
||||
|
||||
**증상**: Telegram 메시지가 전송되지 않음
|
||||
|
||||
**해결 방법**:
|
||||
1. `.env` 파일에서 `TELEGRAM_BOT_TOKEN`과 `TELEGRAM_CHAT_ID` 확인
|
||||
2. Telegram 설정 확인 스크립트 실행:
|
||||
```bash
|
||||
python check_telegram.py
|
||||
```
|
||||
3. 봇 폴링 서비스 로그 확인:
|
||||
```bash
|
||||
tail -f data/logs/app.log | grep "텔레그램"
|
||||
```
|
||||
4. 중복 봇 인스턴스 확인 및 종료:
|
||||
```bash
|
||||
# Windows
|
||||
tasklist | findstr python
|
||||
taskkill /F /PID <process_id>
|
||||
|
||||
# Linux
|
||||
ps aux | grep python
|
||||
kill -9 <process_id>
|
||||
```
|
||||
|
||||
### SocketIO 연결 실패
|
||||
|
||||
**증상**: 실시간 업데이트가 작동하지 않음
|
||||
|
||||
**해결 방법**:
|
||||
1. 브라우저 콘솔에서 에러 확인
|
||||
2. SocketIO 모드 변경:
|
||||
```env
|
||||
# Windows
|
||||
SOCKETIO_ASYNC_MODE=threading
|
||||
|
||||
# Linux
|
||||
SOCKETIO_ASYNC_MODE=eventlet
|
||||
```
|
||||
3. 방화벽 설정 확인
|
||||
4. CORS 설정 확인 (app.py):
|
||||
```python
|
||||
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||
```
|
||||
|
||||
### 데이터베이스 마이그레이션 오류
|
||||
|
||||
**증상**: `flask db upgrade` 실행 시 오류 발생
|
||||
|
||||
**해결 방법**:
|
||||
1. 현재 마이그레이션 상태 확인:
|
||||
```bash
|
||||
flask db current
|
||||
```
|
||||
2. 마이그레이션 히스토리 확인:
|
||||
```bash
|
||||
flask db history
|
||||
```
|
||||
3. 데이터베이스 백업 후 재생성:
|
||||
```bash
|
||||
# 백업
|
||||
cp backend/instance/site.db backend/instance/site.db.backup
|
||||
|
||||
# 재생성
|
||||
rm backend/instance/site.db
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
### 파일 업로드 실패
|
||||
|
||||
**증상**: XML 파일 업로드 시 오류 발생
|
||||
|
||||
**해결 방법**:
|
||||
1. 파일 크기 제한 확인 (`.env`):
|
||||
```env
|
||||
MAX_CONTENT_LENGTH=10485760 # 10MB
|
||||
```
|
||||
2. 디렉토리 권한 확인:
|
||||
```bash
|
||||
# Linux
|
||||
chmod -R 755 data/
|
||||
```
|
||||
3. 디스크 공간 확인:
|
||||
```bash
|
||||
df -h
|
||||
```
|
||||
|
||||
### Redfish API 연결 실패
|
||||
|
||||
**증상**: iDRAC 서버 연결 시 타임아웃 또는 인증 오류
|
||||
|
||||
**해결 방법**:
|
||||
1. iDRAC IP 주소 및 네트워크 연결 확인
|
||||
2. iDRAC 사용자명/비밀번호 확인
|
||||
3. SSL 검증 비활성화 (`.env`):
|
||||
```env
|
||||
REDFISH_VERIFY_SSL=false
|
||||
```
|
||||
4. 타임아웃 시간 증가:
|
||||
```env
|
||||
REDFISH_TIMEOUT=30
|
||||
```
|
||||
5. iDRAC Redfish 서비스 활성화 확인
|
||||
|
||||
### 세션 타임아웃 문제
|
||||
|
||||
**증상**: 로그인 후 자주 로그아웃됨
|
||||
|
||||
**해결 방법**:
|
||||
1. 세션 타임아웃 시간 증가 (`.env`):
|
||||
```env
|
||||
SESSION_MINUTES=60
|
||||
```
|
||||
2. 쿠키 설정 확인 (`config.py`):
|
||||
```python
|
||||
SESSION_COOKIE_SECURE = False # HTTP 환경
|
||||
REMEMBER_COOKIE_SECURE = False
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,7 +17,7 @@ from backend.services.logger import setup_logging
|
||||
from backend.services import watchdog_handler
|
||||
|
||||
# 텔레그램 서비스 (별도 모듈)에서 가져옴
|
||||
from telegram_bot_service import run_polling as telegram_run_polling
|
||||
from backend.services.telegram_bot_service import run_polling as telegram_run_polling
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -178,7 +178,7 @@ if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not app.config.get("DEBUG"):
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
host = os.getenv("FLASK_HOST", "0.0.0.0")
|
||||
port = int(os.getenv("FLASK_PORT", 5000))
|
||||
port = int(os.getenv("FLASK_PORT", 6050))
|
||||
debug = os.getenv("FLASK_DEBUG", "true").lower() == "true"
|
||||
|
||||
# python app.py로 직접 실행 시(use_reloader=False)에는 위 조건문에서 실행되지 않을 수 있으므로
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class StructureValidator(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.stack = []
|
||||
self.important_divs = {
|
||||
'fileTabsContent': None,
|
||||
'processed': None,
|
||||
'repository': None,
|
||||
'repoTabsContent': None,
|
||||
'pills-backup': None
|
||||
}
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'div':
|
||||
attr_dict = dict(attrs)
|
||||
div_id = attr_dict.get('id', '')
|
||||
div_class = attr_dict.get('class', '')
|
||||
line_no = self.getpos()[0]
|
||||
|
||||
self.stack.append({
|
||||
'tag': 'div',
|
||||
'id': div_id,
|
||||
'class': div_class,
|
||||
'line': line_no
|
||||
})
|
||||
|
||||
if div_id in self.important_divs:
|
||||
self.important_divs[div_id] = {
|
||||
'line': line_no,
|
||||
'class': div_class,
|
||||
'depth': len(self.stack)
|
||||
}
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == 'div' and self.stack:
|
||||
self.stack.pop()
|
||||
|
||||
def validate(self, html_content):
|
||||
self.feed(html_content)
|
||||
|
||||
print("=== IMPORTANT DIV INFORMATION ===\n")
|
||||
for div_id, info in self.important_divs.items():
|
||||
if info:
|
||||
print(f"{div_id}:")
|
||||
print(f" Line: {info['line']}")
|
||||
print(f" Class: {info['class']}")
|
||||
print(f" Depth: {info['depth']}")
|
||||
print()
|
||||
|
||||
# Check if processed and repository are siblings
|
||||
if self.important_divs['processed'] and self.important_divs['repository']:
|
||||
proc_depth = self.important_divs['processed']['depth']
|
||||
repo_depth = self.important_divs['repository']['depth']
|
||||
|
||||
print("=== RELATIONSHIP CHECK ===")
|
||||
print(f"#processed depth: {proc_depth}")
|
||||
print(f"#repository depth: {repo_depth}")
|
||||
|
||||
if proc_depth == repo_depth:
|
||||
print("✓ They are SIBLINGS (same depth) - CORRECT")
|
||||
else:
|
||||
print("✗ They are NOT siblings (different depth) - PROBLEM!")
|
||||
print(f" Depth difference: {abs(proc_depth - repo_depth)}")
|
||||
|
||||
# Read the file
|
||||
file_path = r"d:\Code\iDRAC_Info\idrac_info\backend\templates\index.html"
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
validator = StructureValidator()
|
||||
validator.validate(content)
|
||||
@@ -0,0 +1,66 @@
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class TagMatcher(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.stack = []
|
||||
self.div_map = {} # line -> info
|
||||
self.errors = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'div':
|
||||
attr_dict = dict(attrs)
|
||||
div_id = attr_dict.get('id', '')
|
||||
div_class = attr_dict.get('class', '')
|
||||
line_no = self.getpos()[0]
|
||||
|
||||
info = {
|
||||
'tag': 'div',
|
||||
'id': div_id,
|
||||
'class': div_class,
|
||||
'start_line': line_no,
|
||||
'parent': self.stack[-1]['id'] if self.stack else None
|
||||
}
|
||||
self.stack.append(info)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == 'div':
|
||||
if self.stack:
|
||||
info = self.stack.pop()
|
||||
info['end_line'] = self.getpos()[0]
|
||||
|
||||
# Store important divs
|
||||
if info['id'] in ['fileTabsContent', 'processed', 'repository', 'repoTabsContent', 'pills-backup', 'pills-mac', 'pills-guid', 'pills-gpu']:
|
||||
self.div_map[info['id']] = info
|
||||
else:
|
||||
self.errors.append(f"Unexpected closing div at line {self.getpos()[0]}")
|
||||
|
||||
def validate(self, html_content):
|
||||
self.feed(html_content)
|
||||
|
||||
print("=== DIV STRUCTURE MAP ===\n")
|
||||
order = ['fileTabsContent', 'processed', 'repository', 'repoTabsContent', 'pills-backup', 'pills-mac', 'pills-guid', 'pills-gpu']
|
||||
|
||||
for div_id in order:
|
||||
if div_id in self.div_map:
|
||||
info = self.div_map[div_id]
|
||||
print(f"ID: {div_id}")
|
||||
print(f" Start: {info['start_line']}")
|
||||
print(f" End: {info['end_line']}")
|
||||
print(f" Parent: {info['parent']}")
|
||||
print()
|
||||
else:
|
||||
print(f"ID: {div_id} - NOT FOUND or NOT CLOSED properly\n")
|
||||
|
||||
if self.stack:
|
||||
print("\n=== UNCLOSED DIVS ===")
|
||||
for info in self.stack:
|
||||
print(f"Unclosed div starting at line {info['start_line']} (ID: {info['id']}, Class: {info['class']})")
|
||||
|
||||
# Read the file
|
||||
file_path = r"d:\Code\iDRAC_Info\idrac_info\backend\templates\index.html"
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
matcher = TagMatcher()
|
||||
matcher.validate(content)
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class DivStructurePrinter(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.stack = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'div':
|
||||
attr_dict = dict(attrs)
|
||||
ident = attr_dict.get('id', '')
|
||||
if ident in ['fileTabsContent', 'processed', 'repository', 'repoTabsContent', 'pills-backup']:
|
||||
print(f"OPEN {ident:15} at line {self.getpos()[0]} (Stack depth: {len(self.stack)})")
|
||||
self.stack.append(ident)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == 'div':
|
||||
if self.stack:
|
||||
ident = self.stack.pop()
|
||||
if ident in ['fileTabsContent', 'processed', 'repository', 'repoTabsContent', 'pills-backup']:
|
||||
print(f"CLOSE {ident:15} at line {self.getpos()[0]} (Stack depth: {len(self.stack)})")
|
||||
|
||||
with open(r"d:\Code\iDRAC_Info\idrac_info\backend\templates\index.html", "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
parser = DivStructurePrinter()
|
||||
parser.feed(content)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
BIOS Baseline 모델
|
||||
backend/models/bios_baseline.py
|
||||
|
||||
Redfish로 가져온 표준 BIOS 설정을 저장하고 관리하는 모델
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from backend.models.user import db
|
||||
|
||||
class BiosBaseline(db.Model):
|
||||
"""BIOS 설정 기준값 모델 - Redfish로 가져온 표준 설정"""
|
||||
__tablename__ = 'bios_baselines'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# 기본 정보
|
||||
name = db.Column(db.String(100), nullable=False, unique=True) # 예: "R6615_GPU_Standard_2024"
|
||||
server_model = db.Column(db.String(100), nullable=False) # 예: "PowerEdge R6615"
|
||||
server_type = db.Column(db.String(50)) # 예: "GPU Server", "Storage Server"
|
||||
description = db.Column(db.String(500)) # 설명
|
||||
|
||||
# Redfish로 가져온 원본 데이터
|
||||
bios_data = db.Column(db.JSON, nullable=False) # BIOS 설정값 전체 (JSON)
|
||||
bios_metadata = db.Column(db.JSON, nullable=True) # BIOS 메타데이터 (AttributeName -> DisplayName 매핑)
|
||||
idrac_data = db.Column(db.JSON, nullable=True) # iDRAC 설정값 (JSON)
|
||||
idrac_metadata = db.Column(db.JSON, nullable=True) # iDRAC 메타데이터 (DisplayName 매핑)
|
||||
raid_data = db.Column(db.JSON, nullable=True) # RAID/Storage 구성 (JSON)
|
||||
nic_data = db.Column(db.JSON, nullable=True) # NIC 설정 (Adapters)
|
||||
boot_data = db.Column(db.JSON, nullable=True) # 부팅 설정 (Order)
|
||||
firmware_data = db.Column(db.JSON, nullable=True) # 펌웨어 목록
|
||||
|
||||
# 메타데이터
|
||||
source_ip = db.Column(db.String(50)) # 이 baseline을 생성한 서버 IP
|
||||
source_service_tag = db.Column(db.String(50)) # 원본 서버 Service Tag
|
||||
bios_version = db.Column(db.String(50)) # BIOS 버전
|
||||
total_settings = db.Column(db.Integer) # 총 설정 항목 수
|
||||
|
||||
# 복사 관계 (어떤 baseline에서 복사되었는지)
|
||||
copied_from_id = db.Column(db.Integer, db.ForeignKey('bios_baselines.id'), nullable=True)
|
||||
|
||||
# 상태 및 관리
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_by = db.Column(db.String(100)) # 생성자
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계 설정
|
||||
copied_from = db.relationship('BiosBaseline', remote_side=[id], backref='copies')
|
||||
|
||||
def to_dict(self, include_data=False):
|
||||
"""
|
||||
딕셔너리로 변환
|
||||
include_data: True면 bios_data, idrac_data, raid_data 등 상세 데이터 포함 (상세 조회 시)
|
||||
"""
|
||||
data = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'server_model': self.server_model,
|
||||
'server_type': self.server_type,
|
||||
'description': self.description,
|
||||
'source_ip': self.source_ip,
|
||||
'source_service_tag': self.source_service_tag,
|
||||
'bios_version': self.bios_version,
|
||||
'total_settings': self.total_settings,
|
||||
'copied_from_id': self.copied_from_id,
|
||||
'is_active': self.is_active,
|
||||
'created_by': self.created_by,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
if include_data:
|
||||
data['bios_data'] = self.bios_data
|
||||
data['bios_metadata'] = self.bios_metadata
|
||||
data['idrac_data'] = self.idrac_data
|
||||
data['idrac_metadata'] = self.idrac_metadata
|
||||
data['raid_data'] = self.raid_data
|
||||
data['nic_data'] = self.nic_data
|
||||
data['boot_data'] = self.boot_data
|
||||
data['firmware_data'] = self.firmware_data
|
||||
|
||||
return data
|
||||
|
||||
def __repr__(self):
|
||||
return f'<BiosBaseline {self.name} ({self.server_model})>'
|
||||
@@ -0,0 +1,36 @@
|
||||
from datetime import datetime
|
||||
from backend.models.user import db
|
||||
|
||||
class ServerConfigSnapshot(db.Model):
|
||||
"""
|
||||
서버 설정 스냅샷 (BIOS, iDRAC, System 등)
|
||||
"""
|
||||
__tablename__ = 'server_config_snapshots'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
server_id = db.Column(db.Integer, db.ForeignKey('idrac_servers.id'), nullable=False)
|
||||
|
||||
# 설정 유형: bios, idrac, system
|
||||
config_type = db.Column(db.String(20), nullable=False)
|
||||
|
||||
# JSON 데이터 저장
|
||||
data = db.Column(db.JSON, nullable=True)
|
||||
|
||||
# 데이터 해시 (변경 감지용 - Optional)
|
||||
hash_value = db.Column(db.String(64), nullable=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 관계 설정 (IdracServer와의 관계는 IdracServer 모델에서 backref로 설정될 수도 있음)
|
||||
server = db.relationship('IdracServer', backref=db.backref('config_snapshots', lazy=True))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'server_id': self.server_id,
|
||||
'server_name': self.server.name if self.server else 'Unknown',
|
||||
'config_type': self.config_type,
|
||||
'data': self.data,
|
||||
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
}
|
||||
@@ -12,6 +12,9 @@ from .idrac_routes import register_idrac_routes
|
||||
from .catalog_sync import catalog_bp
|
||||
from .scp_routes import scp_bp
|
||||
from .drm_sync import drm_sync_bp
|
||||
from .server_config_routes import server_config_bp
|
||||
from .bios_baseline_routes import register_bios_baseline_routes
|
||||
from .script_manager import script_manager_bp
|
||||
|
||||
|
||||
def register_routes(app: Flask, socketio=None) -> None:
|
||||
@@ -28,3 +31,14 @@ def register_routes(app: Flask, socketio=None) -> None:
|
||||
app.register_blueprint(catalog_bp)
|
||||
app.register_blueprint(scp_bp)
|
||||
app.register_blueprint(drm_sync_bp)
|
||||
app.register_blueprint(server_config_bp)
|
||||
register_bios_baseline_routes(app)
|
||||
app.register_blueprint(script_manager_bp)
|
||||
|
||||
# CSRF 제외 - server_config API 엔드포인트
|
||||
try:
|
||||
csrf = app.extensions.get('csrf')
|
||||
if csrf:
|
||||
csrf.exempt(server_config_bp)
|
||||
except Exception as e:
|
||||
app.logger.warning(f"CSRF exemption failed for server_config_bp: {e}")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,789 @@
|
||||
"""
|
||||
BIOS Baseline 관리 라우트
|
||||
backend/routes/bios_baseline_routes.py
|
||||
|
||||
BIOS Baseline 생성, 조회, 수정, 삭제 및 비교 기능 제공
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify, current_app
|
||||
from backend.models.user import db
|
||||
from backend.models.bios_baseline import BiosBaseline
|
||||
from backend.services.idrac_redfish_client import DellRedfishClient
|
||||
from datetime import datetime
|
||||
import json
|
||||
from sqlalchemy import text
|
||||
|
||||
# 비교 제외 대상 키 (서버별 고유값 등)
|
||||
IGNORED_KEYS = {
|
||||
'BIOS': {
|
||||
'ServiceTag', 'AssetTag', 'SerialNumber', 'SystemServiceTag', 'SystemAssetTag', 'SystemSerialNumber'
|
||||
},
|
||||
'iDRAC': {
|
||||
'SystemID', 'DNSDomainName', 'DNSRacName', 'DNSDomainNameFromDHCP',
|
||||
'IPv4.1.Address', 'IPv4.1.Gateway', 'IPv4.1.Netmask',
|
||||
'IPv4Static.1.Address', 'IPv4Static.1.Gateway', 'IPv4Static.1.Netmask',
|
||||
'IPv6.1.Address', 'IPv6.1.Gateway',
|
||||
'MACAddress', 'PermanentMACAddress'
|
||||
}
|
||||
}
|
||||
|
||||
def compare_dictionaries(expected, actual, section_name, diff_list, metadata=None):
|
||||
"""딕셔너리 비교 헬퍼 함수"""
|
||||
if not expected:
|
||||
return 0, 0
|
||||
|
||||
matched = 0
|
||||
mismatched = 0
|
||||
|
||||
ignored = IGNORED_KEYS.get(section_name, set())
|
||||
|
||||
for key, exp_val in expected.items():
|
||||
if key in ignored:
|
||||
continue
|
||||
|
||||
act_val = actual.get(key)
|
||||
# 문자열로 변환하여 비교 (타입 불일치 방지)
|
||||
if str(act_val) == str(exp_val):
|
||||
matched += 1
|
||||
# 일치하는 항목도 결과에 포함
|
||||
diff_item = {
|
||||
'section': section_name,
|
||||
'setting_name': key,
|
||||
'expected': exp_val,
|
||||
'actual': act_val,
|
||||
'status': 'match'
|
||||
}
|
||||
if metadata and key in metadata:
|
||||
diff_item['display_name'] = metadata[key]
|
||||
diff_list.append(diff_item)
|
||||
else:
|
||||
mismatched += 1
|
||||
|
||||
diff_item = {
|
||||
'section': section_name,
|
||||
'setting_name': key,
|
||||
'expected': exp_val,
|
||||
'actual': act_val,
|
||||
'status': 'mismatch'
|
||||
}
|
||||
|
||||
# Display Name 추가
|
||||
if metadata and key in metadata:
|
||||
diff_item['display_name'] = metadata[key]
|
||||
|
||||
diff_list.append(diff_item)
|
||||
return matched, mismatched
|
||||
|
||||
def compare_raid_config(expected, actual, diff_list):
|
||||
"""RAID 구성 비교"""
|
||||
if not expected or 'Controllers' not in expected:
|
||||
return 0, 0
|
||||
|
||||
matched = 0
|
||||
mismatched = 0
|
||||
total_checks = 0
|
||||
|
||||
# 컨트롤러별 비교
|
||||
exp_ctrls = {c.get('Name'): c for c in expected.get('Controllers', [])}
|
||||
act_ctrls = {c.get('Name'): c for c in actual.get('Controllers', [])}
|
||||
|
||||
for name, exp_c in exp_ctrls.items():
|
||||
act_c = act_ctrls.get(name)
|
||||
|
||||
if not act_c:
|
||||
mismatched += 1
|
||||
diff_list.append({
|
||||
'section': 'RAID',
|
||||
'setting_name': f"Controller [{name}]",
|
||||
'expected': "Present",
|
||||
'actual': "Missing",
|
||||
'status': 'mismatch'
|
||||
})
|
||||
continue
|
||||
|
||||
matched += 1 # 컨트롤러 존재함
|
||||
diff_list.append({
|
||||
'section': 'RAID',
|
||||
'setting_name': f"Controller [{name}]",
|
||||
'expected': "Present",
|
||||
'actual': "Present",
|
||||
'status': 'match'
|
||||
})
|
||||
|
||||
# 펌웨어 버전 비교
|
||||
exp_fw = exp_c.get('FirmwareVersion')
|
||||
act_fw = act_c.get('FirmwareVersion')
|
||||
if exp_fw and (exp_fw == act_fw):
|
||||
matched += 1
|
||||
diff_list.append({
|
||||
'section': 'RAID',
|
||||
'setting_name': f"Controller [{name}] Firmware",
|
||||
'expected': exp_fw,
|
||||
'actual': act_fw,
|
||||
'status': 'match'
|
||||
})
|
||||
else:
|
||||
mismatched += 1
|
||||
diff_list.append({
|
||||
'section': 'RAID',
|
||||
'setting_name': f"Controller [{name}] Firmware",
|
||||
'expected': exp_fw,
|
||||
'actual': act_fw,
|
||||
'status': 'mismatch'
|
||||
})
|
||||
|
||||
# Volumes (Virtual Disks) 비교
|
||||
exp_vols = {v.get('Name'): v for v in exp_c.get('Volumes', [])}
|
||||
act_vols = {v.get('Name'): v for v in act_c.get('Volumes', [])}
|
||||
|
||||
# Volume 개수 확인
|
||||
if len(exp_vols) != len(act_vols):
|
||||
diff_list.append({
|
||||
'section': 'RAID',
|
||||
'setting_name': f"Controller [{name}] Volume Count",
|
||||
'expected': len(exp_vols),
|
||||
'actual': len(act_vols),
|
||||
'status': 'warning'
|
||||
})
|
||||
|
||||
for vname, exp_v in exp_vols.items():
|
||||
act_v = act_vols.get(vname)
|
||||
if not act_v:
|
||||
mismatched += 1
|
||||
diff_list.append({
|
||||
'section': 'RAID',
|
||||
'setting_name': f"Volume [{name}/{vname}]",
|
||||
'expected': "Present",
|
||||
'actual': "Missing",
|
||||
'status': 'mismatch'
|
||||
})
|
||||
continue
|
||||
|
||||
# RAID Level 비교
|
||||
if exp_v.get('RaidType') == act_v.get('RaidType'):
|
||||
matched += 1
|
||||
diff_list.append({
|
||||
'section': 'RAID',
|
||||
'setting_name': f"Volume [{name}/{vname}] RAID Level",
|
||||
'expected': exp_v.get('RaidType'),
|
||||
'actual': act_v.get('RaidType'),
|
||||
'status': 'match'
|
||||
})
|
||||
else:
|
||||
mismatched += 1
|
||||
diff_list.append({
|
||||
'section': 'RAID',
|
||||
'setting_name': f"Volume [{name}/{vname}] RAID Level",
|
||||
'expected': exp_v.get('RaidType'),
|
||||
'actual': act_v.get('RaidType'),
|
||||
'status': 'mismatch'
|
||||
})
|
||||
|
||||
# 용량 비교 (오차 허용 없이)
|
||||
if exp_v.get('CapacityBytes') == act_v.get('CapacityBytes'):
|
||||
matched += 1
|
||||
diff_list.append({
|
||||
'section': 'RAID',
|
||||
'setting_name': f"Volume [{name}/{vname}] Capacity",
|
||||
'expected': f"{exp_v.get('CapacityBytes', 0) / (1024**3):.2f} GB",
|
||||
'actual': f"{act_v.get('CapacityBytes', 0) / (1024**3):.2f} GB",
|
||||
'status': 'match'
|
||||
})
|
||||
else:
|
||||
mismatched += 1
|
||||
diff_list.append({
|
||||
'section': 'RAID',
|
||||
'setting_name': f"Volume [{name}/{vname}] Capacity",
|
||||
'expected': f"{exp_v.get('CapacityBytes', 0) / (1024**3):.2f} GB",
|
||||
'actual': f"{act_v.get('CapacityBytes', 0) / (1024**3):.2f} GB",
|
||||
'status': 'mismatch'
|
||||
})
|
||||
|
||||
return matched, mismatched
|
||||
|
||||
bios_baseline_bp = Blueprint('bios_baseline', __name__, url_prefix='/bios-baseline')
|
||||
|
||||
# ========================================
|
||||
# 페이지 라우트
|
||||
# ========================================
|
||||
|
||||
@bios_baseline_bp.route('/')
|
||||
def index():
|
||||
"""BIOS Baseline 비교 메인 페이지"""
|
||||
return render_template('bios_baseline.html')
|
||||
|
||||
# ========================================
|
||||
# Baseline 관리 API
|
||||
# ========================================
|
||||
|
||||
@bios_baseline_bp.route('/api/baselines', methods=['GET'])
|
||||
def get_baselines():
|
||||
"""
|
||||
등록된 baseline 목록 조회
|
||||
Query params:
|
||||
- server_model: 특정 모델 필터링
|
||||
- server_type: 특정 타입 필터링
|
||||
- include_data: true면 bios_data도 포함
|
||||
"""
|
||||
try:
|
||||
server_model = request.args.get('server_model')
|
||||
server_type = request.args.get('server_type')
|
||||
include_data = request.args.get('include_data', 'false').lower() == 'true'
|
||||
|
||||
query = BiosBaseline.query.filter_by(is_active=True)
|
||||
|
||||
if server_model:
|
||||
query = query.filter_by(server_model=server_model)
|
||||
if server_type:
|
||||
query = query.filter_by(server_type=server_type)
|
||||
|
||||
baselines = query.order_by(BiosBaseline.created_at.desc()).all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'baselines': [b.to_dict(include_data=include_data) for b in baselines]
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@bios_baseline_bp.route('/api/baselines/<int:baseline_id>', methods=['GET'])
|
||||
def get_baseline(baseline_id):
|
||||
"""특정 baseline 상세 조회 (bios_data 포함)"""
|
||||
try:
|
||||
baseline = BiosBaseline.query.get(baseline_id)
|
||||
if not baseline:
|
||||
return jsonify({'success': False, 'message': 'Baseline을 찾을 수 없습니다'})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'baseline': baseline.to_dict(include_data=True)
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
def compare_nic_config(baseline_nic, target_nic, diff_list):
|
||||
"""NIC 구성 비교 (ID 기준 매칭)"""
|
||||
matched = 0
|
||||
mismatched = 0
|
||||
|
||||
if not baseline_nic and not target_nic:
|
||||
return 0, 0
|
||||
if not baseline_nic:
|
||||
diff_list.append({'section': 'NIC', 'setting_name': 'NIC Configuration', 'expected': 'Present', 'actual': 'Missing', 'status': 'mismatch'})
|
||||
return 0, 1
|
||||
if not target_nic:
|
||||
diff_list.append({'section': 'NIC', 'setting_name': 'NIC Configuration', 'expected': 'Missing', 'actual': 'Present', 'status': 'mismatch'})
|
||||
return 0, 1
|
||||
|
||||
# Adapters 리스트를 ID 기준 딕셔너리로 변환
|
||||
def to_dict_by_id(adapters):
|
||||
return {a.get('Id', 'Unknown'): a for a in adapters.get('Adapters', [])}
|
||||
|
||||
base_map = to_dict_by_id(baseline_nic)
|
||||
target_map = to_dict_by_id(target_nic)
|
||||
|
||||
all_ids = set(base_map.keys()) | set(target_map.keys())
|
||||
|
||||
for aid in all_ids:
|
||||
b_adp = base_map.get(aid)
|
||||
t_adp = target_map.get(aid)
|
||||
|
||||
if not b_adp:
|
||||
mismatched += 1
|
||||
diff_list.append({'section': 'NIC', 'setting_name': f"NIC [{aid}]", 'expected': 'None', 'actual': 'Added', 'status': 'mismatch'})
|
||||
continue
|
||||
if not t_adp:
|
||||
mismatched += 1
|
||||
diff_list.append({'section': 'NIC', 'setting_name': f"NIC [{aid}]", 'expected': 'Present', 'actual': 'Missing', 'status': 'mismatch'})
|
||||
continue
|
||||
|
||||
matched += 1 # Adapter exists in both
|
||||
diff_list.append({'section': 'NIC', 'setting_name': f"NIC [{aid}]", 'expected': 'Present', 'actual': 'Present', 'status': 'match'})
|
||||
|
||||
# 어댑터 속성 비교 (Firmware 등)
|
||||
for field in ['FirmwareVersion', 'Model']:
|
||||
bv = b_adp.get(field)
|
||||
tv = t_adp.get(field)
|
||||
if bv == tv:
|
||||
matched += 1
|
||||
diff_list.append({'section': 'NIC', 'setting_name': f"NIC [{aid}] {field}", 'expected': bv, 'actual': tv, 'status': 'match'})
|
||||
else:
|
||||
mismatched += 1
|
||||
diff_list.append({'section': 'NIC', 'setting_name': f"NIC [{aid}] {field}", 'expected': bv, 'actual': tv, 'status': 'mismatch'})
|
||||
|
||||
# DeviceFunctions 비교
|
||||
b_funcs = {f.get('Id'): f for f in b_adp.get('DeviceFunctions', [])}
|
||||
t_funcs = {f.get('Id'): f for f in t_adp.get('DeviceFunctions', [])}
|
||||
|
||||
for fid in set(b_funcs.keys()) | set(t_funcs.keys()):
|
||||
bf = b_funcs.get(fid)
|
||||
tf = t_funcs.get(fid)
|
||||
|
||||
prefix = f"NIC [{aid}] Func [{fid}]"
|
||||
|
||||
if not bf:
|
||||
# Baseline에 없는데 Target에 있음 (일반적이지 않음, 설정 변경일 수 있음)
|
||||
# This is an 'added' function, count as mismatch
|
||||
mismatched += 1
|
||||
diff_list.append({'section': 'NIC', 'setting_name': prefix, 'expected': 'None', 'actual': 'Added', 'status': 'mismatch'})
|
||||
continue
|
||||
if not tf:
|
||||
mismatched += 1
|
||||
diff_list.append({'section': 'NIC', 'setting_name': prefix, 'expected': 'Present', 'actual': 'Missing', 'status': 'mismatch'})
|
||||
continue
|
||||
|
||||
matched += 1 # Function exists in both
|
||||
diff_list.append({'section': 'NIC', 'setting_name': prefix, 'expected': 'Present', 'actual': 'Present', 'status': 'match'})
|
||||
|
||||
# 속성 비교 (BootMode, iSCSI, Ethernet)
|
||||
if bf.get('NetBootPMMode') == tf.get('NetBootPMMode'):
|
||||
matched += 1
|
||||
diff_list.append({'section': 'NIC', 'setting_name': f"{prefix} BootMode", 'expected': bf.get('NetBootPMMode'), 'actual': tf.get('NetBootPMMode'), 'status': 'match'})
|
||||
else:
|
||||
mismatched += 1
|
||||
diff_list.append({'section': 'NIC', 'setting_name': f"{prefix} BootMode", 'expected': bf.get('NetBootPMMode'), 'actual': tf.get('NetBootPMMode'), 'status': 'mismatch'})
|
||||
|
||||
# Add more specific NIC settings comparison here if needed
|
||||
# For example, comparing specific Ethernet or iSCSI settings
|
||||
# For now, we'll consider the presence and BootMode.
|
||||
|
||||
return matched, mismatched
|
||||
|
||||
@bios_baseline_bp.route('/api/baselines/create-from-server', methods=['POST'])
|
||||
def create_baseline_from_server():
|
||||
"""
|
||||
Redfish로 서버에서 BIOS 설정을 가져와 새 baseline 생성
|
||||
Request: {
|
||||
"ip_address": "10.10.0.1",
|
||||
"username": "root",
|
||||
"password": "calvin",
|
||||
"name": "R6615_GPU_Standard_2024",
|
||||
"server_type": "GPU Server",
|
||||
"description": "표준 GPU 서버 설정",
|
||||
"created_by": "admin"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
# 필수 필드 체크
|
||||
if not all(k in data for k in ['ip_address', 'username', 'password', 'name']):
|
||||
return jsonify({'success': False, 'message': '필수 필드 누락'})
|
||||
|
||||
# 이름 중복 체크
|
||||
if BiosBaseline.query.filter_by(name=data['name'], is_active=True).first():
|
||||
return jsonify({'success': False, 'message': '이미 존재하는 Baseline 이름입니다'})
|
||||
|
||||
# 1. Redfish로 BIOS 설정 가져오기
|
||||
client = DellRedfishClient(data['ip_address'], data['username'], data['password'])
|
||||
|
||||
if not client.check_connection():
|
||||
return jsonify({'success': False, 'message': '서버 연결 실패'})
|
||||
|
||||
# 2. 서버 정보 및 BIOS 설정 가져오기
|
||||
system_info = client.get_system_info_detailed()
|
||||
bios_data = client.get_bios_attributes_all()
|
||||
# BIOS Registry (Display Name) 가져오기
|
||||
bios_metadata = client.get_bios_attribute_registry()
|
||||
|
||||
idrac_data = client.get_idrac_attributes_all()
|
||||
# iDRAC Registry (Display Name) 가져오기
|
||||
idrac_metadata = client.get_idrac_attribute_registry()
|
||||
|
||||
raid_data = client.get_storage_configuration()
|
||||
nic_data = client.get_nic_configuration()
|
||||
boot_data = client.get_boot_settings()
|
||||
firmware_data = client.get_firmware_baseline()
|
||||
|
||||
if not bios_data:
|
||||
return jsonify({'success': False, 'message': 'BIOS 설정 가져오기 실패'})
|
||||
|
||||
# 3. Baseline 생성
|
||||
baseline = BiosBaseline(
|
||||
name=data['name'],
|
||||
server_model=system_info.get('Model', 'Unknown'),
|
||||
server_type=data.get('server_type'),
|
||||
description=data.get('description'),
|
||||
bios_data=bios_data,
|
||||
bios_metadata=bios_metadata, # 메타데이터 저장
|
||||
idrac_data=idrac_data,
|
||||
idrac_metadata=idrac_metadata, # iDRAC 메타데이터 저장
|
||||
raid_data=raid_data,
|
||||
nic_data=nic_data,
|
||||
boot_data=boot_data,
|
||||
firmware_data=firmware_data,
|
||||
source_ip=data['ip_address'],
|
||||
source_service_tag=system_info.get('ServiceTag'),
|
||||
bios_version=bios_data.get('BiosVersion'),
|
||||
total_settings=len(bios_data) + len(idrac_data) + len(raid_data.get('Controllers', [])) + len(nic_data.get('Adapters', [])) + len(boot_data.get('BootSources', [])) + len(firmware_data.get('FirmwareInventory', [])),
|
||||
created_by=data.get('created_by', 'admin')
|
||||
)
|
||||
|
||||
db.session.add(baseline)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Baseline 생성 완료',
|
||||
'baseline': baseline.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': f'오류: {str(e)}'})
|
||||
|
||||
@bios_baseline_bp.route('/api/baselines/<int:baseline_id>/duplicate', methods=['POST'])
|
||||
def duplicate_baseline(baseline_id):
|
||||
"""
|
||||
기존 baseline을 복사하여 새 baseline 생성
|
||||
Request: {
|
||||
"new_name": "R6615_GPU_ClientA",
|
||||
"description": "고객사 A 맞춤 설정"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
original = BiosBaseline.query.get(baseline_id)
|
||||
|
||||
if not original:
|
||||
return jsonify({'success': False, 'message': '원본 Baseline을 찾을 수 없습니다'})
|
||||
|
||||
# 이름 중복 체크
|
||||
if BiosBaseline.query.filter_by(name=data['new_name'], is_active=True).first():
|
||||
return jsonify({'success': False, 'message': '이미 존재하는 Baseline 이름입니다'})
|
||||
|
||||
# 복사 생성
|
||||
new_baseline = BiosBaseline(
|
||||
name=data['new_name'],
|
||||
server_model=original.server_model,
|
||||
server_type=original.server_type,
|
||||
description=data.get('description', f"{original.name}의 복사본"),
|
||||
bios_data=original.bios_data.copy() if original.bios_data else {},
|
||||
bios_metadata=original.bios_metadata.copy() if original.bios_metadata else {}, # 메타데이터 복사
|
||||
idrac_data=original.idrac_data.copy() if original.idrac_data else {},
|
||||
idrac_metadata=original.idrac_metadata.copy() if original.idrac_metadata else {}, # iDRAC 메타데이터 복사
|
||||
raid_data=original.raid_data.copy() if original.raid_data else {}, # JSON 복사
|
||||
nic_data=original.nic_data.copy() if original.nic_data else {},
|
||||
boot_data=original.boot_data.copy() if original.boot_data else {},
|
||||
firmware_data=original.firmware_data.copy() if original.firmware_data else {},
|
||||
source_ip=original.source_ip,
|
||||
source_service_tag=original.source_service_tag,
|
||||
bios_version=original.bios_version,
|
||||
total_settings=original.total_settings,
|
||||
copied_from_id=original.id,
|
||||
created_by=data.get('created_by', 'admin')
|
||||
)
|
||||
|
||||
db.session.add(new_baseline)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Baseline 복사 완료',
|
||||
'baseline': new_baseline.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': f'오류: {str(e)}'})
|
||||
|
||||
@bios_baseline_bp.route('/api/baselines/<int:baseline_id>', methods=['PUT'])
|
||||
def update_baseline(baseline_id):
|
||||
"""
|
||||
Baseline 수정 (메타데이터 및 bios_data)
|
||||
Request: {
|
||||
"name": "새 이름",
|
||||
"description": "새 설명",
|
||||
"bios_data": { ... } // 선택사항
|
||||
}
|
||||
"""
|
||||
try:
|
||||
baseline = BiosBaseline.query.get_or_404(baseline_id)
|
||||
data = request.json
|
||||
|
||||
# 이름 변경 시 중복 체크
|
||||
if 'name' in data and data['name'] != baseline.name:
|
||||
if BiosBaseline.query.filter_by(name=data['name'], is_active=True).first():
|
||||
return jsonify({'success': False, 'message': '이미 존재하는 Baseline 이름입니다'})
|
||||
baseline.name = data['name']
|
||||
|
||||
# 메타데이터 업데이트
|
||||
if 'description' in data:
|
||||
baseline.description = data['description']
|
||||
if 'server_type' in data:
|
||||
baseline.server_type = data['server_type']
|
||||
|
||||
# BIOS 데이터 업데이트 (선택사항)
|
||||
if 'bios_data' in data:
|
||||
baseline.bios_data = data['bios_data']
|
||||
if 'bios_metadata' in data:
|
||||
baseline.bios_metadata = data['bios_metadata']
|
||||
if 'idrac_data' in data:
|
||||
baseline.idrac_data = data['idrac_data']
|
||||
if 'idrac_metadata' in data:
|
||||
baseline.idrac_metadata = data['idrac_metadata']
|
||||
if 'raid_data' in data:
|
||||
baseline.raid_data = data['raid_data']
|
||||
if 'nic_data' in data:
|
||||
baseline.nic_data = data['nic_data']
|
||||
if 'boot_data' in data:
|
||||
baseline.boot_data = data['boot_data']
|
||||
if 'firmware_data' in data:
|
||||
baseline.firmware_data = data['firmware_data']
|
||||
|
||||
# 총 설정 수 재계산
|
||||
total = 0
|
||||
if baseline.bios_data: total += len(baseline.bios_data)
|
||||
if baseline.idrac_data: total += len(baseline.idrac_data)
|
||||
if baseline.raid_data and 'Controllers' in baseline.raid_data: total += len(baseline.raid_data['Controllers'])
|
||||
if baseline.nic_data and 'Adapters' in baseline.nic_data: total += len(baseline.nic_data['Adapters'])
|
||||
if baseline.boot_data and 'BootSources' in baseline.boot_data: total += len(baseline.boot_data['BootSources'])
|
||||
if baseline.firmware_data and 'FirmwareInventory' in baseline.firmware_data: total += len(baseline.firmware_data['FirmwareInventory'])
|
||||
baseline.total_settings = total
|
||||
|
||||
baseline.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Baseline 수정 완료',
|
||||
'baseline': baseline.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': f'오류: {str(e)}'})
|
||||
|
||||
@bios_baseline_bp.route('/api/baselines/<int:baseline_id>', methods=['DELETE'])
|
||||
def delete_baseline(baseline_id):
|
||||
"""Baseline 삭제 (Soft Delete)"""
|
||||
try:
|
||||
baseline = BiosBaseline.query.get(baseline_id)
|
||||
if not baseline:
|
||||
return jsonify({'success': False, 'message': 'Baseline을 찾을 수 없습니다'})
|
||||
|
||||
baseline.is_active = False
|
||||
# 이름 충돌 방지를 위해 이름 변경 (Soft Delete 시)
|
||||
# 예: "MyServer" -> "MyServer_deleted_1678901234"
|
||||
baseline.name = f"{baseline.name}_deleted_{int(datetime.utcnow().timestamp())}"
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Baseline 삭제 완료'})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
# ========================================
|
||||
# 비교 API
|
||||
# ========================================
|
||||
|
||||
@bios_baseline_bp.route('/api/compare', methods=['POST'])
|
||||
def compare_with_baseline():
|
||||
"""
|
||||
IP 주소로 서버 BIOS 설정을 가져와 baseline과 비교
|
||||
Request: {
|
||||
"ip_addresses": ["10.10.0.1", "10.10.0.2"],
|
||||
"baseline_id": 1,
|
||||
"username": "root",
|
||||
"password": "calvin"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
baseline = BiosBaseline.query.get(data['baseline_id'])
|
||||
|
||||
if not baseline:
|
||||
return jsonify({'success': False, 'message': 'Baseline을 찾을 수 없습니다'})
|
||||
|
||||
results = []
|
||||
|
||||
# 병렬 처리를 위한 함수 정의
|
||||
def process_server(ip):
|
||||
try:
|
||||
# 1. Redfish로 현재 BIOS 설정 가져오기
|
||||
client = DellRedfishClient(ip, data['username'], data['password'])
|
||||
|
||||
if not client.check_connection():
|
||||
return {
|
||||
'ip': ip,
|
||||
'status': 'connection_failed',
|
||||
'message': '서버 연결 실패'
|
||||
}
|
||||
|
||||
# 2. Redfish로 현재 설정 가져오기
|
||||
system_info = client.get_system_info_detailed()
|
||||
current_bios = client.get_bios_attributes_all()
|
||||
current_idrac = client.get_idrac_attributes_all()
|
||||
current_raid = client.get_storage_configuration()
|
||||
current_nic = client.get_nic_configuration()
|
||||
current_boot = client.get_boot_settings()
|
||||
current_firmware = client.get_firmware_baseline()
|
||||
|
||||
if not current_bios:
|
||||
return {
|
||||
'ip': ip,
|
||||
'status': 'error',
|
||||
'message': 'BIOS 설정 가져오기 실패'
|
||||
}
|
||||
|
||||
# 3. Baseline과 비교
|
||||
differences = []
|
||||
total_matched = 0
|
||||
total_mismatched = 0
|
||||
|
||||
# 3-1. BIOS 비교
|
||||
b_matched, b_mismatched = compare_dictionaries(baseline.bios_data, current_bios, 'BIOS', differences, baseline.bios_metadata)
|
||||
total_matched += b_matched
|
||||
total_mismatched += b_mismatched
|
||||
|
||||
# 3-2. iDRAC 비교
|
||||
if baseline.idrac_data:
|
||||
i_matched, i_mismatched = compare_dictionaries(baseline.idrac_data, current_idrac, 'iDRAC', differences, baseline.idrac_metadata)
|
||||
total_matched += i_matched
|
||||
total_mismatched += i_mismatched
|
||||
|
||||
# 3-3. RAID 비교
|
||||
if baseline.raid_data:
|
||||
r_matched, r_mismatched = compare_raid_config(baseline.raid_data, current_raid, differences)
|
||||
total_matched += r_matched
|
||||
total_mismatched += r_mismatched
|
||||
|
||||
# 3-4. NIC 비교
|
||||
if baseline.nic_data:
|
||||
n_matched, n_mismatched = compare_nic_config(baseline.nic_data, current_nic, differences)
|
||||
total_matched += n_matched
|
||||
total_mismatched += n_mismatched
|
||||
|
||||
# 3-5. Boot 비교
|
||||
if baseline.boot_data:
|
||||
boot_matched, boot_mismatched = compare_dictionaries(baseline.boot_data, current_boot, 'Boot', differences)
|
||||
total_matched += boot_matched
|
||||
total_mismatched += boot_mismatched
|
||||
|
||||
# 3-6. Firmware 비교
|
||||
if baseline.firmware_data:
|
||||
fw_matched, fw_mismatched = compare_dictionaries(baseline.firmware_data, current_firmware, 'Firmware', differences)
|
||||
total_matched += fw_matched
|
||||
total_mismatched += fw_mismatched
|
||||
|
||||
# 4. 결과 저장
|
||||
match_rate = round((total_matched / (total_matched + total_mismatched)) * 100, 2) if (total_matched + total_mismatched) > 0 else 0
|
||||
|
||||
return {
|
||||
'ip': ip,
|
||||
'status': 'success',
|
||||
'server_model': system_info.get('Model'),
|
||||
'service_tag': system_info.get('ServiceTag'),
|
||||
'bios_version': current_bios.get('BiosVersion'), # Corrected from system_info.get('BiosVersion')
|
||||
'total_items': total_matched + total_mismatched,
|
||||
'matched_items': total_matched,
|
||||
'mismatched_items': total_mismatched,
|
||||
'match_rate': match_rate,
|
||||
'differences': differences
|
||||
}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc() # Keep traceback for debugging in logs
|
||||
return {
|
||||
'ip': ip,
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}
|
||||
|
||||
import concurrent.futures
|
||||
results = []
|
||||
max_workers = min(len(data['ip_addresses']), 20) # 최대 20개 동시 실행
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_ip = {executor.submit(process_server, ip): ip for ip in data['ip_addresses']}
|
||||
for future in concurrent.futures.as_completed(future_to_ip):
|
||||
ip = future_to_ip[future]
|
||||
try:
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
results.append({
|
||||
'ip': ip,
|
||||
'status': 'error',
|
||||
'message': f"예상치 못한 오류: {str(e)}"
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'baseline': baseline.to_dict(),
|
||||
'results': results
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': f'오류: {str(e)}'})
|
||||
|
||||
# ========================================
|
||||
# Blueprint 등록 함수
|
||||
# ========================================
|
||||
|
||||
def register_bios_baseline_routes(app):
|
||||
"""
|
||||
bios_baseline_routes 등록 및 초기화
|
||||
"""
|
||||
app.register_blueprint(bios_baseline_bp)
|
||||
|
||||
# CSRF 제외
|
||||
try:
|
||||
csrf = app.extensions.get('csrf')
|
||||
if csrf:
|
||||
csrf.exempt(bios_baseline_bp)
|
||||
except Exception as e:
|
||||
app.logger.warning(f"CSRF exemption failed for bios_baseline_bp: {e}")
|
||||
|
||||
# DB 마이그레이션: 필요한 컬럼이 없으면 추가
|
||||
# SQLite 등에서 ALTER TABLE은 제한적일 수 있으나 여기서는 단순 컬럼 추가 시도
|
||||
with app.app_context():
|
||||
inspector = db.inspect(db.engine)
|
||||
|
||||
# Ensure table exists before checking columns
|
||||
db.create_all()
|
||||
|
||||
columns = [c['name'] for c in inspector.get_columns('bios_baselines')]
|
||||
|
||||
if 'idrac_data' not in columns:
|
||||
print("[INFO] Migrating BIOS Baseline table: Adding idrac_data")
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE bios_baselines ADD COLUMN idrac_data JSON"))
|
||||
conn.commit()
|
||||
|
||||
if 'bios_metadata' not in columns:
|
||||
print("[INFO] Migrating BIOS Baseline table: Adding bios_metadata")
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE bios_baselines ADD COLUMN bios_metadata JSON"))
|
||||
conn.commit()
|
||||
|
||||
if 'idrac_metadata' not in columns:
|
||||
print("[INFO] Migrating BIOS Baseline table: Adding idrac_metadata")
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE bios_baselines ADD COLUMN idrac_metadata JSON"))
|
||||
conn.commit()
|
||||
|
||||
if 'raid_data' not in columns:
|
||||
print("[INFO] Migrating BIOS Baseline table: Adding raid_data")
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE bios_baselines ADD COLUMN raid_data JSON"))
|
||||
conn.commit()
|
||||
|
||||
if 'nic_data' not in columns:
|
||||
print("[INFO] Migrating BIOS Baseline table: Adding nic_data")
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE bios_baselines ADD COLUMN nic_data JSON"))
|
||||
conn.commit()
|
||||
|
||||
if 'boot_data' not in columns:
|
||||
print("[INFO] Migrating BIOS Baseline table: Adding boot_data")
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE bios_baselines ADD COLUMN boot_data JSON"))
|
||||
conn.commit()
|
||||
|
||||
if 'firmware_data' not in columns:
|
||||
print("[INFO] Migrating BIOS Baseline table: Adding firmware_data")
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE bios_baselines ADD COLUMN firmware_data JSON"))
|
||||
conn.commit()
|
||||
|
||||
@@ -66,7 +66,21 @@ def view_file():
|
||||
base = Path(Config.BACKUP_FOLDER)
|
||||
safe_date = Path(date).name if date else ""
|
||||
target = (base / safe_date / safe_name).resolve()
|
||||
elif folder == "server_info":
|
||||
base = Path(Config.BACKUP_FOLDER)
|
||||
safe_date = Path(date).name if date else ""
|
||||
target = (base / safe_date / safe_name).resolve()
|
||||
elif folder == "mac":
|
||||
base = Path(Config.MAC_FOLDER)
|
||||
target = (base / safe_name).resolve()
|
||||
elif folder == "guid":
|
||||
base = Path(Config.GUID_FOLDER)
|
||||
target = (base / safe_name).resolve()
|
||||
elif folder == "gpu":
|
||||
base = Path(Config.GPU_FOLDER)
|
||||
target = (base / safe_name).resolve()
|
||||
else:
|
||||
# Default: Processed files (Staging)
|
||||
base = Path(Config.IDRAC_INFO_FOLDER)
|
||||
target = (base / safe_name).resolve()
|
||||
|
||||
|
||||
+96
-963
File diff suppressed because it is too large
Load Diff
@@ -1,669 +0,0 @@
|
||||
"""
|
||||
Dell iDRAC 멀티 서버 펌웨어 관리 라우트
|
||||
backend/routes/idrac_routes.py
|
||||
- CSRF 보호 제외 추가
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify, current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
from datetime import datetime
|
||||
from backend.services.idrac_redfish_client import DellRedfishClient
|
||||
from backend.models.idrac_server import IdracServer, db
|
||||
from flask_socketio import emit
|
||||
import threading
|
||||
|
||||
# Blueprint 생성
|
||||
idrac_bp = Blueprint('idrac', __name__, url_prefix='/idrac')
|
||||
|
||||
# 설정
|
||||
UPLOAD_FOLDER = 'uploads/firmware'
|
||||
ALLOWED_EXTENSIONS = {'exe', 'bin'}
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500MB
|
||||
|
||||
def allowed_file(filename):
|
||||
"""허용된 파일 형식 확인"""
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
# ========================================
|
||||
# 메인 페이지
|
||||
# ========================================
|
||||
|
||||
@idrac_bp.route('/')
|
||||
def index():
|
||||
"""iDRAC 멀티 서버 관리 메인 페이지"""
|
||||
return render_template('idrac_firmware.html')
|
||||
|
||||
# ========================================
|
||||
# 서버 관리 API
|
||||
# ========================================
|
||||
|
||||
@idrac_bp.route('/api/servers', methods=['GET'])
|
||||
def get_servers():
|
||||
"""등록된 서버 목록 조회"""
|
||||
try:
|
||||
group = request.args.get('group') # 그룹 필터
|
||||
|
||||
query = IdracServer.query.filter_by(is_active=True)
|
||||
if group and group != 'all':
|
||||
query = query.filter_by(group_name=group)
|
||||
|
||||
servers = query.order_by(IdracServer.name).all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'servers': [s.to_dict() for s in servers]
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/servers', methods=['POST'])
|
||||
def add_server():
|
||||
"""서버 추가"""
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
# 필수 필드 확인
|
||||
if not all([data.get('name'), data.get('ip_address'), data.get('password')]):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '필수 필드를 모두 입력하세요 (서버명, IP, 비밀번호)'
|
||||
})
|
||||
|
||||
# 중복 IP 확인
|
||||
existing = IdracServer.query.filter_by(ip_address=data['ip_address']).first()
|
||||
if existing:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'이미 등록된 IP입니다: {data["ip_address"]}'
|
||||
})
|
||||
|
||||
# 서버 생성
|
||||
server = IdracServer(
|
||||
name=data['name'],
|
||||
ip_address=data['ip_address'],
|
||||
username=data.get('username', 'root'),
|
||||
password=data['password'],
|
||||
group_name=data.get('group_name'),
|
||||
location=data.get('location'),
|
||||
model=data.get('model'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
|
||||
db.session.add(server)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'서버 {server.name} 추가 완료',
|
||||
'server': server.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/servers/<int:server_id>', methods=['PUT'])
|
||||
def update_server(server_id):
|
||||
"""서버 정보 수정"""
|
||||
try:
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '서버를 찾을 수 없습니다'
|
||||
})
|
||||
|
||||
data = request.json
|
||||
|
||||
# 업데이트
|
||||
if 'name' in data:
|
||||
server.name = data['name']
|
||||
if 'ip_address' in data:
|
||||
server.ip_address = data['ip_address']
|
||||
if 'username' in data:
|
||||
server.username = data['username']
|
||||
if 'password' in data:
|
||||
server.password = data['password']
|
||||
if 'group_name' in data:
|
||||
server.group_name = data['group_name']
|
||||
if 'location' in data:
|
||||
server.location = data['location']
|
||||
if 'model' in data:
|
||||
server.model = data['model']
|
||||
if 'notes' in data:
|
||||
server.notes = data['notes']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '서버 정보 수정 완료',
|
||||
'server': server.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/servers/<int:server_id>', methods=['DELETE'])
|
||||
def delete_server(server_id):
|
||||
"""서버 삭제 (소프트 삭제)"""
|
||||
try:
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '서버를 찾을 수 없습니다'
|
||||
})
|
||||
|
||||
server.is_active = False
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'서버 {server.name} 삭제 완료'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/groups', methods=['GET'])
|
||||
def get_groups():
|
||||
"""등록된 그룹 목록"""
|
||||
try:
|
||||
groups = db.session.query(IdracServer.group_name)\
|
||||
.filter(IdracServer.is_active == True)\
|
||||
.filter(IdracServer.group_name.isnot(None))\
|
||||
.distinct()\
|
||||
.all()
|
||||
|
||||
group_list = [g[0] for g in groups if g[0]]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'groups': group_list
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
# ========================================
|
||||
# 연결 및 상태 확인 API
|
||||
# ========================================
|
||||
|
||||
@idrac_bp.route('/api/servers/<int:server_id>/test', methods=['POST'])
|
||||
def test_connection(server_id):
|
||||
"""단일 서버 연결 테스트"""
|
||||
try:
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '서버를 찾을 수 없습니다'
|
||||
})
|
||||
|
||||
client = DellRedfishClient(server.ip_address, server.username, server.password)
|
||||
|
||||
if client.check_connection():
|
||||
server.status = 'online'
|
||||
server.last_connected = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'{server.name} 연결 성공'
|
||||
})
|
||||
else:
|
||||
server.status = 'offline'
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'{server.name} 연결 실패'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/servers/test-multi', methods=['POST'])
|
||||
def test_connections_multi():
|
||||
"""다중 서버 일괄 연결 테스트"""
|
||||
try:
|
||||
data = request.json
|
||||
server_ids = data.get('server_ids', [])
|
||||
|
||||
if not server_ids:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '서버를 선택하세요'
|
||||
})
|
||||
|
||||
results = []
|
||||
|
||||
for server_id in server_ids:
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
continue
|
||||
|
||||
try:
|
||||
client = DellRedfishClient(server.ip_address, server.username, server.password)
|
||||
|
||||
if client.check_connection():
|
||||
server.status = 'online'
|
||||
server.last_connected = datetime.utcnow()
|
||||
results.append({
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'success': True,
|
||||
'message': '연결 성공'
|
||||
})
|
||||
else:
|
||||
server.status = 'offline'
|
||||
results.append({
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'success': False,
|
||||
'message': '연결 실패'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
server.status = 'offline'
|
||||
results.append({
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'success': False,
|
||||
'message': str(e)
|
||||
})
|
||||
|
||||
db.session.commit()
|
||||
|
||||
success_count = sum(1 for r in results if r['success'])
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'results': results,
|
||||
'summary': {
|
||||
'total': len(results),
|
||||
'success': success_count,
|
||||
'failed': len(results) - success_count
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/servers/<int:server_id>/firmware', methods=['GET'])
|
||||
def get_server_firmware(server_id):
|
||||
"""단일 서버 펌웨어 버전 조회"""
|
||||
try:
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '서버를 찾을 수 없습니다'
|
||||
})
|
||||
|
||||
client = DellRedfishClient(server.ip_address, server.username, server.password)
|
||||
inventory = client.get_firmware_inventory()
|
||||
|
||||
# BIOS 버전 업데이트
|
||||
for item in inventory:
|
||||
if 'BIOS' in item.get('Name', ''):
|
||||
server.current_bios = item.get('Version')
|
||||
break
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': inventory
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
# ========================================
|
||||
# 멀티 서버 펌웨어 업로드 API
|
||||
# ========================================
|
||||
|
||||
@idrac_bp.route('/api/upload-multi', methods=['POST'])
|
||||
def upload_multi():
|
||||
"""다중 서버에 펌웨어 일괄 업로드"""
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '파일이 없습니다'
|
||||
})
|
||||
|
||||
file = request.files['file']
|
||||
server_ids_str = request.form.get('server_ids')
|
||||
|
||||
if not server_ids_str:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '서버를 선택하세요'
|
||||
})
|
||||
|
||||
server_ids = [int(x) for x in server_ids_str.split(',')]
|
||||
|
||||
if file.filename == '':
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '파일이 선택되지 않았습니다'
|
||||
})
|
||||
|
||||
if not allowed_file(file.filename):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '허용되지 않은 파일 형식입니다 (.exe, .bin만 가능)'
|
||||
})
|
||||
|
||||
# 로컬에 임시 저장
|
||||
filename = secure_filename(file.filename)
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
local_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
file.save(local_path)
|
||||
|
||||
# 백그라운드 스레드로 업로드 시작
|
||||
from backend.services import watchdog_handler
|
||||
socketio = watchdog_handler.socketio
|
||||
|
||||
def upload_to_servers():
|
||||
results = []
|
||||
|
||||
for idx, server_id in enumerate(server_ids):
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 진행 상황 전송
|
||||
if socketio:
|
||||
socketio.emit('upload_progress', {
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'status': 'uploading',
|
||||
'progress': 0,
|
||||
'message': '업로드 시작...'
|
||||
})
|
||||
|
||||
server.status = 'updating'
|
||||
db.session.commit()
|
||||
|
||||
client = DellRedfishClient(server.ip_address, server.username, server.password)
|
||||
result = client.upload_firmware_staged(local_path)
|
||||
|
||||
if result['success']:
|
||||
server.status = 'online'
|
||||
server.last_updated = datetime.utcnow()
|
||||
|
||||
results.append({
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'success': True,
|
||||
'job_id': result['job_id'],
|
||||
'message': '업로드 완료'
|
||||
})
|
||||
|
||||
if socketio:
|
||||
socketio.emit('upload_progress', {
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'status': 'completed',
|
||||
'progress': 100,
|
||||
'message': '업로드 완료',
|
||||
'job_id': result['job_id']
|
||||
})
|
||||
else:
|
||||
server.status = 'online'
|
||||
|
||||
results.append({
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'success': False,
|
||||
'message': result.get('message', '업로드 실패')
|
||||
})
|
||||
|
||||
if socketio:
|
||||
socketio.emit('upload_progress', {
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'status': 'failed',
|
||||
'progress': 0,
|
||||
'message': result.get('message', '업로드 실패')
|
||||
})
|
||||
|
||||
db.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
server.status = 'online'
|
||||
db.session.commit()
|
||||
|
||||
results.append({
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'success': False,
|
||||
'message': str(e)
|
||||
})
|
||||
|
||||
if socketio:
|
||||
socketio.emit('upload_progress', {
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'status': 'failed',
|
||||
'progress': 0,
|
||||
'message': str(e)
|
||||
})
|
||||
|
||||
# 최종 결과 전송
|
||||
if socketio:
|
||||
success_count = sum(1 for r in results if r['success'])
|
||||
socketio.emit('upload_complete', {
|
||||
'results': results,
|
||||
'summary': {
|
||||
'total': len(results),
|
||||
'success': success_count,
|
||||
'failed': len(results) - success_count
|
||||
}
|
||||
})
|
||||
|
||||
# 스레드 시작
|
||||
thread = threading.Thread(target=upload_to_servers)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'{len(server_ids)}대 서버에 업로드 시작',
|
||||
'filename': filename
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'업로드 오류: {str(e)}'
|
||||
})
|
||||
|
||||
# ========================================
|
||||
# 기존 단일 서버 API (호환성 유지)
|
||||
# ========================================
|
||||
|
||||
@idrac_bp.route('/api/files/local', methods=['GET'])
|
||||
def list_local_files():
|
||||
"""로컬에 저장된 DUP 파일 목록"""
|
||||
try:
|
||||
files = []
|
||||
|
||||
if os.path.exists(UPLOAD_FOLDER):
|
||||
for filename in os.listdir(UPLOAD_FOLDER):
|
||||
filepath = os.path.join(UPLOAD_FOLDER, filename)
|
||||
if os.path.isfile(filepath):
|
||||
file_size = os.path.getsize(filepath)
|
||||
files.append({
|
||||
'name': filename,
|
||||
'size': file_size,
|
||||
'size_mb': round(file_size / (1024 * 1024), 2),
|
||||
'uploaded_at': datetime.fromtimestamp(
|
||||
os.path.getmtime(filepath)
|
||||
).strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'files': files
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/files/local/<filename>', methods=['DELETE'])
|
||||
def delete_local_file(filename):
|
||||
"""로컬 DUP 파일 삭제"""
|
||||
try:
|
||||
filepath = os.path.join(UPLOAD_FOLDER, secure_filename(filename))
|
||||
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'{filename} 삭제 완료'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '파일을 찾을 수 없습니다'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'삭제 오류: {str(e)}'
|
||||
})
|
||||
|
||||
# ========================================
|
||||
# 서버 재부팅 API (멀티)
|
||||
# ========================================
|
||||
|
||||
@idrac_bp.route('/api/servers/reboot-multi', methods=['POST'])
|
||||
def reboot_servers_multi():
|
||||
"""선택한 서버들 일괄 재부팅"""
|
||||
try:
|
||||
data = request.json
|
||||
server_ids = data.get('server_ids', [])
|
||||
reboot_type = data.get('type', 'GracefulRestart')
|
||||
|
||||
if not server_ids:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '서버를 선택하세요'
|
||||
})
|
||||
|
||||
results = []
|
||||
|
||||
for server_id in server_ids:
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
continue
|
||||
|
||||
try:
|
||||
client = DellRedfishClient(server.ip_address, server.username, server.password)
|
||||
result = client.reboot_server(reboot_type)
|
||||
|
||||
if result:
|
||||
results.append({
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'success': True,
|
||||
'message': '재부팅 시작'
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'success': False,
|
||||
'message': '재부팅 실패'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
results.append({
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'success': False,
|
||||
'message': str(e)
|
||||
})
|
||||
|
||||
success_count = sum(1 for r in results if r['success'])
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'results': results,
|
||||
'summary': {
|
||||
'total': len(results),
|
||||
'success': success_count,
|
||||
'failed': len(results) - success_count
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
|
||||
# Blueprint 등록 함수
|
||||
def register_idrac_routes(app):
|
||||
"""
|
||||
iDRAC 멀티 서버 관리 Blueprint 등록
|
||||
|
||||
Args:
|
||||
app: Flask 애플리케이션 인스턴스
|
||||
"""
|
||||
# uploads 디렉토리 생성
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
|
||||
# Blueprint 등록
|
||||
app.register_blueprint(idrac_bp)
|
||||
|
||||
# CSRF 보호 제외 - iDRAC API 엔드포인트
|
||||
try:
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
csrf = CSRFProtect()
|
||||
# API 엔드포인트는 CSRF 검증 제외
|
||||
csrf.exempt(idrac_bp)
|
||||
except:
|
||||
pass # CSRF 설정 실패해도 계속 진행
|
||||
|
||||
# DB 테이블 생성
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
+153
-16
@@ -98,7 +98,7 @@ def index():
|
||||
end_page = min(start_page + 9, total_pages)
|
||||
|
||||
# 4. 백업 폴더 목록
|
||||
backup_dirs = [d for d in backup_dir.iterdir() if d.is_dir()]
|
||||
backup_dirs = [d for d in backup_dir.iterdir() if d.is_dir() and d.name != "archive"]
|
||||
backup_dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
backup_page = int(request.args.get("backup_page", 1))
|
||||
@@ -119,31 +119,109 @@ def index():
|
||||
total_pages=total_pages,
|
||||
start_page=start_page,
|
||||
end_page=end_page,
|
||||
backup_files=backup_files,
|
||||
backup_files={}, # [DISABLED] Use server_info_files instead
|
||||
total_backup_pages=total_backup_pages,
|
||||
backup_page=backup_page,
|
||||
server_info_files=backup_files, # Server Info (Paginated)
|
||||
scripts=all_scripts, # 기존 리스트 호환
|
||||
grouped_scripts=grouped_scripts_sorted, # 카테고리별 분류
|
||||
xml_files=xml_files,
|
||||
# [NEW] Repositories
|
||||
mac_files=_get_file_info_list(Config.MAC_FOLDER),
|
||||
guid_files=_get_file_info_list(Config.GUID_FOLDER),
|
||||
gpu_files=_get_file_info_list(Config.GPU_FOLDER),
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _get_backup_info(folder_path):
|
||||
"""
|
||||
폴더 내의 하위 폴더들을 순회하며 파일 목록 정보를 반환 (백업/아카이브 용)
|
||||
"""
|
||||
path = Path(folder_path)
|
||||
if not path.exists():
|
||||
return {}
|
||||
|
||||
dirs = [d for d in path.iterdir() if d.is_dir() and d.name != "archive"]
|
||||
dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
result = {}
|
||||
for d in dirs:
|
||||
files = [f.name for f in d.iterdir() if f.is_file()]
|
||||
result[d.name] = {"files": files, "count": len(files)}
|
||||
return result
|
||||
|
||||
def _get_file_info_list(folder_path_str):
|
||||
"""
|
||||
폴더 내 파일들의 상세 정보(이름, 크기, 수정일)를 리스트로 반환
|
||||
"""
|
||||
path = Path(folder_path_str)
|
||||
if not path.exists():
|
||||
return []
|
||||
|
||||
files = []
|
||||
try:
|
||||
for f in path.glob("*"):
|
||||
if f.is_file():
|
||||
stat = f.stat()
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"size": stat.st_size,
|
||||
"mtime": stat.st_mtime, # timestamp for sorting
|
||||
"date": time.strftime('%Y-%m-%d %H:%M', time.localtime(stat.st_mtime))
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Error reading folder {folder_path_str}: {e}")
|
||||
return []
|
||||
|
||||
# 기본 정렬: 최신순
|
||||
return sorted(files, key=lambda x: x['mtime'], reverse=True)
|
||||
|
||||
|
||||
@main_bp.route("/process_ips", methods=["POST"])
|
||||
@login_required
|
||||
def process_ips():
|
||||
ips = request.form.get("ips")
|
||||
selected_script = request.form.get("script")
|
||||
selected_xml_file = request.form.get("xmlFile")
|
||||
|
||||
if not ips or not selected_script:
|
||||
return jsonify({"error": "IP 주소와 스크립트를 모두 입력하세요."}), 400
|
||||
job_type = request.form.get("job_type")
|
||||
|
||||
# 변수 초기화
|
||||
selected_script = ""
|
||||
profile_name = None
|
||||
xml_file_path = None
|
||||
if selected_script == "02-set_config.py" and selected_xml_file:
|
||||
xml_path = Path(Config.XML_FOLDER) / selected_xml_file
|
||||
if not xml_path.exists():
|
||||
return jsonify({"error": "선택한 XML 파일이 존재하지 않습니다."}), 400
|
||||
xml_file_path = str(xml_path)
|
||||
slot_priority = request.form.get("slot_priority") # [NEW] 슬롯 우선순위
|
||||
|
||||
# 작업 유형에 따른 스크립트 및 프로파일 설정
|
||||
if job_type in ["mac", "server_info", "guid", "gpu"]:
|
||||
profile_name = request.form.get("profile")
|
||||
if job_type in ["mac", "guid"] and not profile_name:
|
||||
return jsonify({"error": "프로파일을 선택하세요."}), 400
|
||||
|
||||
if job_type == "mac":
|
||||
selected_script = "unified/collect_mac.py"
|
||||
elif job_type == "server_info":
|
||||
selected_script = "unified/collect_server_info.py"
|
||||
elif job_type == "guid":
|
||||
selected_script = "unified/collect_guid.py"
|
||||
elif job_type == "gpu":
|
||||
selected_script = "unified/collect_gpu.py"
|
||||
profile_name = None # GPU는 프로파일 불필요
|
||||
|
||||
else:
|
||||
# Legacy 모드 (기존 스크립트 선택)
|
||||
selected_script = request.form.get("script")
|
||||
selected_xml_file = request.form.get("xmlFile")
|
||||
|
||||
if not selected_script:
|
||||
return jsonify({"error": "스크립트를 선택하세요."}), 400
|
||||
|
||||
if selected_script == "02-set_config.py" and selected_xml_file:
|
||||
xml_path = Path(Config.XML_FOLDER) / selected_xml_file
|
||||
if not xml_path.exists():
|
||||
return jsonify({"error": "선택한 XML 파일이 존재하지 않습니다."}), 400
|
||||
xml_file_path = str(xml_path)
|
||||
|
||||
if not ips:
|
||||
return jsonify({"error": "IP 주소를 입력하세요."}), 400
|
||||
|
||||
job_id = str(time.time())
|
||||
session["job_id"] = job_id
|
||||
@@ -157,11 +235,11 @@ def process_ips():
|
||||
observer.start()
|
||||
|
||||
future = executor.submit(
|
||||
process_ips_concurrently, ip_files, job_id, observer, selected_script, xml_file_path
|
||||
process_ips_concurrently, ip_files, job_id, observer, selected_script, xml_file_path, profile_name, slot_priority
|
||||
)
|
||||
future.add_done_callback(lambda x: on_complete(job_id))
|
||||
|
||||
logging.info(f"[AJAX] 작업 시작: {job_id}, script: {selected_script}")
|
||||
logging.info(f"[AJAX] 작업 시작: {job_id}, type: {job_type}, script: {selected_script}, profile: {profile_name}")
|
||||
return jsonify({"job_id": job_id})
|
||||
|
||||
|
||||
@@ -220,11 +298,21 @@ def delete_file(filename: str):
|
||||
@main_bp.route("/download_zip", methods=["POST"])
|
||||
@login_required
|
||||
def download_zip():
|
||||
zip_filename = request.form.get("zip_filename", "export")
|
||||
zip_filename = request.form.get("zip_filename", "export").strip()
|
||||
zip_path = Path(Config.TEMP_ZIP_FOLDER) / f"{zip_filename}.zip"
|
||||
|
||||
# 기본 대상은 스테이징 폴더 (IDRAC_INFO_FOLDER)
|
||||
target_dir = Path(Config.IDRAC_INFO_FOLDER)
|
||||
|
||||
# 입력된 이름이 백업 폴더에 존재하는지 확인
|
||||
if zip_filename:
|
||||
possible_backup_path = Path(Config.BACKUP_FOLDER) / zip_filename
|
||||
if possible_backup_path.exists() and possible_backup_path.is_dir():
|
||||
target_dir = possible_backup_path
|
||||
logging.info(f"백업 폴더 ZIP 다운로드 요청: {zip_filename}")
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf:
|
||||
for file in Path(Config.IDRAC_INFO_FOLDER).glob("*"):
|
||||
for file in target_dir.glob("*"):
|
||||
if file.is_file():
|
||||
zipf.write(file, arcname=file.name)
|
||||
|
||||
@@ -281,3 +369,52 @@ def move_backup_files():
|
||||
except Exception as e:
|
||||
logging.error(f"파일 이동 실패: {e}")
|
||||
return jsonify({"success": False, "message": f"이동 중 오류 발생: {str(e)}"}), 500
|
||||
|
||||
|
||||
@main_bp.route("/delete_backup_folder/<folder_name>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_backup_folder(folder_name: str):
|
||||
folder_path = Path(Config.BACKUP_FOLDER) / folder_name
|
||||
|
||||
# 기본 폴더 보호
|
||||
if folder_name == "archive":
|
||||
flash("이 폴더는 삭제할 수 없습니다.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
if folder_path.exists() and folder_path.is_dir():
|
||||
try:
|
||||
shutil.rmtree(folder_path)
|
||||
flash(f"백업 폴더 '{folder_name}'가 삭제되었습니다.", "success")
|
||||
logging.info(f"백업 폴더 삭제됨: {folder_name}")
|
||||
except Exception as e:
|
||||
logging.error(f"백업 폴더 삭제 오류: {e}")
|
||||
flash("폴더 삭제 중 오류가 발생했습니다.", "danger")
|
||||
else:
|
||||
flash("폴더가 존재하지 않습니다.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@main_bp.route("/archive_backup_folder/<folder_name>", methods=["POST"])
|
||||
@login_required
|
||||
def archive_backup_folder(folder_name: str):
|
||||
src_path = Path(Config.BACKUP_FOLDER) / folder_name
|
||||
archive_root = Path(Config.ARCHIVE_FOLDER)
|
||||
archive_root.mkdir(parents=True, exist_ok=True)
|
||||
dst_path = archive_root / folder_name
|
||||
|
||||
if src_path.exists() and src_path.is_dir():
|
||||
try:
|
||||
# 이름 중복 시 처리 (timestamp 추가)
|
||||
if dst_path.exists():
|
||||
timestamp = int(time.time())
|
||||
dst_path = archive_root / f"{folder_name}_{timestamp}"
|
||||
|
||||
shutil.move(str(src_path), str(dst_path))
|
||||
flash(f"'{folder_name}' 폴더가 아카이브(archive)로 이동되었습니다.", "success")
|
||||
logging.info(f"백업 아카이빙 성공: {folder_name} -> {dst_path}")
|
||||
except Exception as e:
|
||||
logging.error(f"백업 아카이빙 오류: {e}")
|
||||
flash("폴더 이동 중 오류가 발생했습니다.", "danger")
|
||||
else:
|
||||
flash("폴더가 존재하지 않습니다.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
스크립트 관리 라우트 및 API
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, jsonify, send_file
|
||||
from flask_login import login_required
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
import json
|
||||
import logging
|
||||
|
||||
script_manager_bp = Blueprint("script_manager", __name__)
|
||||
|
||||
# 프로파일 디렉토리
|
||||
PROFILES_DIR = Path(__file__).parent.parent.parent / "data" / "scripts" / "profiles"
|
||||
UNIFIED_DIR = Path(__file__).parent.parent.parent / "data" / "scripts" / "unified"
|
||||
|
||||
|
||||
@script_manager_bp.route("/script_manager")
|
||||
@login_required
|
||||
def index():
|
||||
"""스크립트 관리 메인 페이지"""
|
||||
return render_template("script_manager.html")
|
||||
|
||||
|
||||
@script_manager_bp.route("/api/profiles", methods=["GET"])
|
||||
@login_required
|
||||
def get_profiles():
|
||||
"""모든 프로파일 목록 (카드 뷰용)"""
|
||||
profiles = []
|
||||
|
||||
for yaml_file in PROFILES_DIR.glob("*.yaml"):
|
||||
try:
|
||||
with open(yaml_file, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
category = yaml_file.stem.replace('_profiles', '') # mac, guid, server_info
|
||||
|
||||
for name, config in data.get('profiles', {}).items():
|
||||
profiles.append({
|
||||
"name": name,
|
||||
"description": config.get("description", ""),
|
||||
"category": category,
|
||||
"items": _count_items(config, category),
|
||||
"is_default": name == "default"
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"프로파일 로드 오류 ({yaml_file}): {e}")
|
||||
|
||||
return jsonify(profiles)
|
||||
|
||||
|
||||
@script_manager_bp.route("/api/profiles/<category>", methods=["GET"])
|
||||
@login_required
|
||||
def get_profiles_by_category(category):
|
||||
"""특정 카테고리의 프로파일 목록"""
|
||||
profiles = []
|
||||
# 파일명 매핑 (server_info -> server_info_profiles.yaml)
|
||||
# category가 mac, guid, server_info 등으로 넘어옴
|
||||
|
||||
yaml_file = PROFILES_DIR / f"{category}_profiles.yaml"
|
||||
|
||||
if not yaml_file.exists():
|
||||
return jsonify({"error": f"프로파일 파일이 없습니다: {category}"}), 404
|
||||
|
||||
try:
|
||||
with open(yaml_file, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
for name, config in data.get('profiles', {}).items():
|
||||
profiles.append({
|
||||
"name": name,
|
||||
"description": config.get("description", ""),
|
||||
"category": category,
|
||||
"items": _count_items(config, category),
|
||||
"is_default": name == "default"
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"프로파일 로드 오류 ({yaml_file}): {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
return jsonify(profiles)
|
||||
|
||||
|
||||
@script_manager_bp.route("/api/profiles/<category>/<name>", methods=["GET"])
|
||||
@login_required
|
||||
def get_profile(category, name):
|
||||
"""특정 프로파일 상세 정보 (편집용)"""
|
||||
profile_file = PROFILES_DIR / f"{category}_profiles.yaml"
|
||||
|
||||
if not profile_file.exists():
|
||||
return jsonify({"error": "프로파일 파일 없음"}), 404
|
||||
|
||||
try:
|
||||
with open(profile_file, 'r', encoding='utf-8') as f:
|
||||
profiles = yaml.safe_load(f)['profiles']
|
||||
|
||||
profile = profiles.get(name)
|
||||
if not profile:
|
||||
return jsonify({"error": "프로파일 없음"}), 404
|
||||
|
||||
# UI 친화적 형식으로 변환
|
||||
return jsonify({
|
||||
"name": name,
|
||||
"description": profile.get("description", ""),
|
||||
"category": category,
|
||||
"config": profile
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"프로파일 로드 오류: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@script_manager_bp.route("/api/profiles/<category>/<name>", methods=["PUT"])
|
||||
@login_required
|
||||
def update_profile(category, name):
|
||||
"""프로파일 업데이트"""
|
||||
profile_file = PROFILES_DIR / f"{category}_profiles.yaml"
|
||||
|
||||
if not profile_file.exists():
|
||||
return jsonify({"error": "프로파일 파일 없음"}), 404
|
||||
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
# 기존 프로파일 로드
|
||||
with open(profile_file, 'r', encoding='utf-8') as f:
|
||||
profiles_data = yaml.safe_load(f)
|
||||
|
||||
# 업데이트
|
||||
profiles_data['profiles'][name] = data.get('config', {})
|
||||
|
||||
# 저장
|
||||
with open(profile_file, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(profiles_data, f, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
return jsonify({"success": True, "message": "프로파일이 저장되었습니다."})
|
||||
except Exception as e:
|
||||
logging.error(f"프로파일 저장 오류: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@script_manager_bp.route("/api/profiles/<category>", methods=["POST"])
|
||||
@login_required
|
||||
def create_profile(category):
|
||||
"""새 프로파일 생성"""
|
||||
profile_file = PROFILES_DIR / f"{category}_profiles.yaml"
|
||||
|
||||
if not profile_file.exists():
|
||||
return jsonify({"error": "프로파일 파일 없음"}), 404
|
||||
|
||||
try:
|
||||
data = request.json
|
||||
name = data.get('name')
|
||||
|
||||
if not name:
|
||||
return jsonify({"error": "프로파일 이름 필요"}), 400
|
||||
|
||||
# 기존 프로파일 로드
|
||||
with open(profile_file, 'r', encoding='utf-8') as f:
|
||||
profiles_data = yaml.safe_load(f)
|
||||
|
||||
# 중복 체크
|
||||
if name in profiles_data['profiles']:
|
||||
return jsonify({"error": "이미 존재하는 프로파일"}), 400
|
||||
|
||||
# 새 프로파일 추가
|
||||
profiles_data['profiles'][name] = data.get('config', {})
|
||||
|
||||
# 저장
|
||||
with open(profile_file, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(profiles_data, f, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
return jsonify({"success": True, "message": "프로파일이 생성되었습니다."})
|
||||
except Exception as e:
|
||||
logging.error(f"프로파일 생성 오류: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@script_manager_bp.route("/api/profiles/<category>/<name>", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_profile(category, name):
|
||||
"""프로파일 삭제"""
|
||||
profile_file = PROFILES_DIR / f"{category}_profiles.yaml"
|
||||
|
||||
if not profile_file.exists():
|
||||
return jsonify({"error": "프로파일 파일 없음"}), 404
|
||||
|
||||
try:
|
||||
# default 프로파일은 삭제 불가
|
||||
if name == "default":
|
||||
return jsonify({"error": "기본 프로파일은 삭제할 수 없습니다."}), 400
|
||||
|
||||
# 기존 프로파일 로드
|
||||
with open(profile_file, 'r', encoding='utf-8') as f:
|
||||
profiles_data = yaml.safe_load(f)
|
||||
|
||||
# 삭제
|
||||
if name in profiles_data['profiles']:
|
||||
del profiles_data['profiles'][name]
|
||||
else:
|
||||
return jsonify({"error": "프로파일 없음"}), 404
|
||||
|
||||
# 저장
|
||||
with open(profile_file, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(profiles_data, f, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
return jsonify({"success": True, "message": "프로파일이 삭제되었습니다."})
|
||||
except Exception as e:
|
||||
logging.error(f"프로파일 삭제 오류: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@script_manager_bp.route("/api/profiles/<category>/<name>/export", methods=["GET"])
|
||||
@login_required
|
||||
def export_profile(category, name):
|
||||
"""프로파일 YAML 파일 다운로드"""
|
||||
profile_file = PROFILES_DIR / f"{category}_profiles.yaml"
|
||||
|
||||
if not profile_file.exists():
|
||||
return jsonify({"error": "프로파일 파일 없음"}), 404
|
||||
|
||||
try:
|
||||
with open(profile_file, 'r', encoding='utf-8') as f:
|
||||
profiles = yaml.safe_load(f)
|
||||
|
||||
# 단일 프로파일만 추출
|
||||
single_profile = {
|
||||
"profiles": {
|
||||
name: profiles['profiles'][name]
|
||||
}
|
||||
}
|
||||
|
||||
# 임시 파일 생성
|
||||
temp_dir = Path(__file__).parent.parent.parent / "data" / "temp_zips"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
export_file = temp_dir / f"{name}_profile.yaml"
|
||||
|
||||
with open(export_file, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(single_profile, f, allow_unicode=True)
|
||||
|
||||
return send_file(export_file, as_attachment=True, download_name=f"{name}_profile.yaml")
|
||||
except Exception as e:
|
||||
logging.error(f"프로파일 내보내기 오류: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
return send_file(export_file, as_attachment=True, download_name=f"{name}_profile.yaml")
|
||||
except Exception as e:
|
||||
logging.error(f"프로파일 내보내기 오류: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@script_manager_bp.route("/api/test_racadm", methods=["POST"])
|
||||
@login_required
|
||||
def test_racadm():
|
||||
"""Racadm 명령어 테스트"""
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
data = request.json
|
||||
ip = data.get("ip")
|
||||
user = data.get("user", "root")
|
||||
password = data.get("password")
|
||||
command = data.get("command")
|
||||
|
||||
if not all([ip, user, password, command]):
|
||||
return jsonify({"success": False, "error": "필수 입력값 누락"}), 400
|
||||
|
||||
# 명령어 구성 (racadm -r <IP> -u <USER> -p <PW> <CMD>)
|
||||
# 보안을 위해 shell=False로 리스트 형태로 전달
|
||||
full_cmd = ["racadm", "-r", ip, "-u", user, "-p", password] + command.split()
|
||||
|
||||
logging.info(f"[Test] Executing racadm on {ip}: {command}")
|
||||
|
||||
# 타임아웃 30초
|
||||
result = subprocess.run(
|
||||
full_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"return_code": result.returncode,
|
||||
"output": result.stdout + (result.stderr if result.stderr else "")
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "명령어 실행 시간 초과 (30초)"
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"[Test] Racadm execution error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
def _count_items(profile: dict, category: str) -> int:
|
||||
"""프로파일에서 수집할 항목 개수 계산"""
|
||||
count = 0
|
||||
|
||||
if category == "mac":
|
||||
count += len(profile.get("nic_patterns", []))
|
||||
if profile.get("infiniband_slots"):
|
||||
count += len(profile["infiniband_slots"])
|
||||
if profile.get("include_idrac_mac"):
|
||||
count += 1
|
||||
elif category == "guid":
|
||||
count += len(profile.get("slot_priority", []))
|
||||
elif category == "gpu":
|
||||
# GUID slot priority + GPU settings
|
||||
count += len(profile.get("slot_priority", []))
|
||||
if profile.get("gpu_settings"):
|
||||
count += 1
|
||||
elif category == "server_info":
|
||||
count += len(profile.get("firmware", []))
|
||||
count += len(profile.get("bios_settings", []))
|
||||
count += len(profile.get("idrac_settings", []))
|
||||
count += len(profile.get("raid_settings", []))
|
||||
count += len(profile.get("custom_items", []))
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def register_script_manager(app):
|
||||
"""블루프린트 등록"""
|
||||
app.register_blueprint(script_manager_bp)
|
||||
@@ -0,0 +1,185 @@
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from backend.models.user import db
|
||||
from backend.models.idrac_server import IdracServer
|
||||
from backend.models.server_config import ServerConfigSnapshot
|
||||
from backend.services.idrac_redfish_client import DellRedfishClient
|
||||
import concurrent.futures
|
||||
import json
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
|
||||
server_config_bp = Blueprint('server_config', __name__, url_prefix='/server-config')
|
||||
|
||||
@server_config_bp.route('/')
|
||||
def index():
|
||||
"""서버 설정 분석 및 비교 페이지"""
|
||||
return render_template('server_config.html')
|
||||
|
||||
def calculate_checksum(data):
|
||||
"""JSON 데이터의 SHA256 체크섬 계산"""
|
||||
json_str = json.dumps(data, sort_keys=True)
|
||||
return hashlib.sha256(json_str.encode('utf-8')).hexdigest()
|
||||
|
||||
@server_config_bp.route('/api/sync', methods=['POST'])
|
||||
def sync_configs():
|
||||
"""
|
||||
선택한 서버들의 설정을 iDRAC에서 가져와 DB에 저장 (동기화)
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
server_ids = data.get('server_ids', [])
|
||||
categories = data.get('categories', ['bios', 'idrac', 'system'])
|
||||
|
||||
if not server_ids:
|
||||
return jsonify({'success': False, 'message': '서버를 선택해주세요.'})
|
||||
|
||||
results = {'success': [], 'failed': []}
|
||||
|
||||
def process_server(server_id):
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
return {'id': server_id, 'status': 'failed', 'message': 'Server not found'}
|
||||
|
||||
client = DellRedfishClient(server.ip_address, server.username, server.password)
|
||||
if not client.check_connection():
|
||||
return {'id': server.id, 'name': server.name, 'status': 'failed', 'message': 'Connection failed'}
|
||||
|
||||
sync_results = []
|
||||
|
||||
# System Info
|
||||
if 'system' in categories:
|
||||
sys_data = client.get_system_info_detailed()
|
||||
if sys_data:
|
||||
save_snapshot(server.id, 'system', sys_data)
|
||||
sync_results.append('system')
|
||||
|
||||
# BIOS Attributes
|
||||
if 'bios' in categories:
|
||||
bios_data = client.get_bios_attributes_all()
|
||||
if bios_data:
|
||||
save_snapshot(server.id, 'bios', bios_data)
|
||||
sync_results.append('bios')
|
||||
|
||||
# iDRAC Attributes
|
||||
if 'idrac' in categories:
|
||||
idrac_data = client.get_idrac_attributes_all()
|
||||
if idrac_data:
|
||||
save_snapshot(server.id, 'idrac', idrac_data)
|
||||
sync_results.append('idrac')
|
||||
|
||||
return {'id': server.id, 'name': server.name, 'status': 'success', 'synced': sync_results}
|
||||
|
||||
def save_snapshot(server_id, config_type, data):
|
||||
# 기존 스냅샷 확인
|
||||
snapshot = ServerConfigSnapshot.query.filter_by(
|
||||
server_id=server_id,
|
||||
config_type=config_type
|
||||
).first()
|
||||
|
||||
new_hash = calculate_checksum(data)
|
||||
|
||||
if snapshot:
|
||||
# 변경사항이 있는 경우에만 업데이트
|
||||
if snapshot.hash_value != new_hash:
|
||||
snapshot.data = data
|
||||
snapshot.hash_value = new_hash
|
||||
snapshot.updated_at = datetime.utcnow() # 명시적 업데이트 시간 갱신
|
||||
else:
|
||||
snapshot = ServerConfigSnapshot(
|
||||
server_id=server_id,
|
||||
config_type=config_type,
|
||||
data=data,
|
||||
hash_value=new_hash
|
||||
)
|
||||
db.session.add(snapshot)
|
||||
|
||||
# 트랜잭션은 개별 commit 혹은 묶어서 commit.
|
||||
# 여기서는 스레드 안전성을 위해 함수 내에서는 session 조작만 하고
|
||||
# 실제 commit은 메인 스레드나 별도 처리 필요하지만, Flask-SQLAlchemy는 scoped_session이므로
|
||||
# 각 요청(스레드)마다 세션이 다를 수 있음.
|
||||
# 하지만 ThreadPoolExecutor 사용 시 app context 문제가 발생할 수 있음.
|
||||
# safe way: return data -> main thread saves. OR push app context.
|
||||
|
||||
# NOTE: For simplicity in this refactor with ThreadPoolExecutor,
|
||||
# we will handle DB operations inside the thread but need app context.
|
||||
# See below for implementation adjustment.
|
||||
return True
|
||||
|
||||
# DB 저장을 위해 app context 필요
|
||||
from flask import current_app
|
||||
app = current_app._get_current_object()
|
||||
|
||||
def process_with_context(server_id):
|
||||
with app.app_context():
|
||||
try:
|
||||
res = process_server(server_id)
|
||||
db.session.commit() # Commit per server
|
||||
return res
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return {'id': server_id, 'status': 'failed', 'message': str(e)}
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
|
||||
future_to_id = {executor.submit(process_with_context, sid): sid for sid in server_ids}
|
||||
|
||||
for future in concurrent.futures.as_completed(future_to_id):
|
||||
try:
|
||||
res = future.result()
|
||||
if res['status'] == 'success':
|
||||
results['success'].append(res)
|
||||
else:
|
||||
results['failed'].append(res)
|
||||
except Exception as e:
|
||||
results['failed'].append({'error': str(e)})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'results': results
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': f"Sync Error: {str(e)}"})
|
||||
|
||||
@server_config_bp.route('/api/fetch', methods=['POST'])
|
||||
def fetch_configs():
|
||||
"""
|
||||
DB에 저장된 서버 설정 스냅샷 조회
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
server_ids = data.get('server_ids', [])
|
||||
categories = data.get('categories', ['bios', 'idrac', 'system'])
|
||||
|
||||
if not server_ids:
|
||||
return jsonify({'success': False, 'message': '서버를 선택해주세요.'})
|
||||
|
||||
results = {}
|
||||
|
||||
for server_id in server_ids:
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
continue
|
||||
|
||||
server_data = {'id': server.id, 'name': server.name, 'ip': server.ip_address}
|
||||
|
||||
for cat in categories:
|
||||
snapshot = ServerConfigSnapshot.query.filter_by(
|
||||
server_id=server_id,
|
||||
config_type=cat
|
||||
).first()
|
||||
|
||||
if snapshot:
|
||||
server_data[cat] = snapshot.data
|
||||
server_data[f'{cat}_updated'] = snapshot.updated_at.isoformat()
|
||||
else:
|
||||
server_data[cat] = None
|
||||
|
||||
results[server_id] = server_data
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'results': results
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': f"Fetch Error: {str(e)}"})
|
||||
@@ -333,7 +333,7 @@ def update_server_list():
|
||||
try:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(Path(Config.SERVER_LIST_FOLDER) / "excel.py")],
|
||||
[sys.executable, str(Path(Config.SERVER_LIST_FOLDER) / "unified_excel_generator.py"), "--mode", "mac"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
@@ -373,7 +373,7 @@ def update_guid_list():
|
||||
logging.info(f"GUID 슬롯 우선순위: {slot_priority}")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(Path(Config.SERVER_LIST_FOLDER) / "GUIDtxtT0Execl.py")],
|
||||
[sys.executable, str(Path(Config.SERVER_LIST_FOLDER) / "unified_excel_generator.py"), "--mode", "guid"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
@@ -417,8 +417,8 @@ def update_gpu_list():
|
||||
# 2) 엑셀 생성 실행 (GPU 프리셋)
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(script_path),
|
||||
"--preset", "gpu",
|
||||
str(Path(Config.SERVER_LIST_FOLDER) / "unified_excel_generator.py"),
|
||||
"--mode", "gpu",
|
||||
"--list-file", str(list_path),
|
||||
]
|
||||
result = subprocess.run(
|
||||
@@ -523,3 +523,77 @@ def scan_network():
|
||||
except Exception as e:
|
||||
logging.error(f"Scan network fatal error: {e}")
|
||||
return jsonify({"success": False, "error": f"서버 내부 오류: {str(e)}"}), 500
|
||||
|
||||
|
||||
@utils_bp.route("/api/system/control", methods=["POST"])
|
||||
@login_required
|
||||
def system_control():
|
||||
"""
|
||||
시스템 제어 명령 실행 (Power ON/OFF, Log Clear 등)
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
action = data.get("action")
|
||||
ips = data.get("ips", [])
|
||||
|
||||
if not action or not ips:
|
||||
return jsonify({"success": False, "error": "Action 또는 IP 목록이 없습니다."}), 400
|
||||
|
||||
# 통합 스크립트 사용
|
||||
script_name = "unified/system_control.py"
|
||||
script_path = Path(Config.SCRIPT_FOLDER) / script_name
|
||||
|
||||
if not script_path.exists():
|
||||
return jsonify({"success": False, "error": f"스크립트 파일을 찾을 수 없습니다: {script_name}"}), 500
|
||||
|
||||
# 임시 IP 파일 생성
|
||||
import tempfile
|
||||
try:
|
||||
# data/temp/uploads 폴더 사용
|
||||
temp_dir = Path(Config.UPLOAD_FOLDER)
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w+', delete=False, dir=temp_dir, suffix=".txt", encoding='utf-8') as tf:
|
||||
tf.write("\n".join(ips))
|
||||
temp_ip_file = tf.name
|
||||
|
||||
# 스크립트 실행: python unified/system_control.py <action> <ip_file>
|
||||
cmd = [sys.executable, str(script_path), action, temp_ip_file]
|
||||
|
||||
logging.info(f"[SystemControl] Executing {action} with {len(ips)} IPs (Unified Script)...")
|
||||
|
||||
# 타임아웃 넉넉하게 (작업에 따라 다름)
|
||||
timeout = 600 if action in ["tsr_collect", "tsr_save"] else 300
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False, # 에러 발생해도 결과 받음
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# 임시 파일 삭제
|
||||
try:
|
||||
os.remove(temp_ip_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if result.returncode == 0:
|
||||
logging.info(f"[SystemControl] Success: {result.stdout}")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"작업({action})이 성공적으로 완료되었습니다.",
|
||||
"details": result.stdout
|
||||
})
|
||||
else:
|
||||
logging.error(f"[SystemControl] Failed: {result.stderr}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"스크립트 실행 중 오류가 발생했습니다.\n{result.stderr}"
|
||||
}), 500
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({"success": False, "error": "스크립트 실행 시간이 초과되었습니다."}), 504
|
||||
except Exception as e:
|
||||
logging.error(f"[SystemControl] Exception: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
@@ -1,399 +0,0 @@
|
||||
"""
|
||||
펌웨어 버전 비교 API 코드
|
||||
idrac_routes.py 파일의 register_idrac_routes 함수 위에 추가하세요
|
||||
"""
|
||||
|
||||
# ========================================
|
||||
# 펌웨어 버전 관리 API
|
||||
# ========================================
|
||||
|
||||
@idrac_bp.route('/api/firmware-versions', methods=['GET'])
|
||||
def get_firmware_versions():
|
||||
"""등록된 최신 펌웨어 버전 목록"""
|
||||
try:
|
||||
server_model = request.args.get('model') # 서버 모델 필터
|
||||
|
||||
query = FirmwareVersion.query.filter_by(is_active=True)
|
||||
|
||||
if server_model:
|
||||
# 특정 모델 또는 범용
|
||||
query = query.filter(
|
||||
(FirmwareVersion.server_model == server_model) |
|
||||
(FirmwareVersion.server_model == None)
|
||||
)
|
||||
|
||||
versions = query.order_by(FirmwareVersion.component_name).all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'versions': [v.to_dict() for v in versions]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/firmware-versions', methods=['POST'])
|
||||
def add_firmware_version():
|
||||
"""최신 펌웨어 버전 등록"""
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
# 필수 필드 확인
|
||||
if not all([data.get('component_name'), data.get('latest_version')]):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '컴포넌트명과 버전을 입력하세요'
|
||||
})
|
||||
|
||||
# 중복 확인 (같은 컴포넌트, 같은 모델)
|
||||
existing = FirmwareVersion.query.filter_by(
|
||||
component_name=data['component_name'],
|
||||
server_model=data.get('server_model')
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'이미 등록된 컴포넌트입니다'
|
||||
})
|
||||
|
||||
# 버전 생성
|
||||
version = FirmwareVersion(
|
||||
component_name=data['component_name'],
|
||||
component_type=data.get('component_type'),
|
||||
vendor=data.get('vendor'),
|
||||
server_model=data.get('server_model'),
|
||||
latest_version=data['latest_version'],
|
||||
release_date=data.get('release_date'),
|
||||
download_url=data.get('download_url'),
|
||||
file_name=data.get('file_name'),
|
||||
file_size_mb=data.get('file_size_mb'),
|
||||
notes=data.get('notes'),
|
||||
is_critical=data.get('is_critical', False)
|
||||
)
|
||||
|
||||
db.session.add(version)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'{version.component_name} 버전 정보 등록 완료',
|
||||
'version': version.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/firmware-versions/<int:version_id>', methods=['PUT'])
|
||||
def update_firmware_version(version_id):
|
||||
"""펌웨어 버전 정보 수정"""
|
||||
try:
|
||||
version = FirmwareVersion.query.get(version_id)
|
||||
if not version:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '버전 정보를 찾을 수 없습니다'
|
||||
})
|
||||
|
||||
data = request.json
|
||||
|
||||
# 업데이트
|
||||
if 'component_name' in data:
|
||||
version.component_name = data['component_name']
|
||||
if 'latest_version' in data:
|
||||
version.latest_version = data['latest_version']
|
||||
if 'release_date' in data:
|
||||
version.release_date = data['release_date']
|
||||
if 'download_url' in data:
|
||||
version.download_url = data['download_url']
|
||||
if 'file_name' in data:
|
||||
version.file_name = data['file_name']
|
||||
if 'notes' in data:
|
||||
version.notes = data['notes']
|
||||
if 'is_critical' in data:
|
||||
version.is_critical = data['is_critical']
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '버전 정보 수정 완료',
|
||||
'version': version.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/firmware-versions/<int:version_id>', methods=['DELETE'])
|
||||
def delete_firmware_version(version_id):
|
||||
"""펌웨어 버전 정보 삭제"""
|
||||
try:
|
||||
version = FirmwareVersion.query.get(version_id)
|
||||
if not version:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '버전 정보를 찾을 수 없습니다'
|
||||
})
|
||||
|
||||
version.is_active = False
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'{version.component_name} 버전 정보 삭제 완료'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/servers/<int:server_id>/firmware/compare', methods=['GET'])
|
||||
def compare_server_firmware(server_id):
|
||||
"""
|
||||
서버 펌웨어 버전 비교
|
||||
현재 버전과 최신 버전을 비교하여 업데이트 필요 여부 확인
|
||||
"""
|
||||
try:
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '서버를 찾을 수 없습니다'
|
||||
})
|
||||
|
||||
# 현재 펌웨어 조회
|
||||
client = DellRedfishClient(server.ip_address, server.username, server.password)
|
||||
current_inventory = client.get_firmware_inventory()
|
||||
|
||||
# 최신 버전 정보 조회
|
||||
latest_versions = FirmwareVersion.query.filter_by(is_active=True).all()
|
||||
|
||||
# 비교 결과
|
||||
comparisons = []
|
||||
|
||||
for current_fw in current_inventory:
|
||||
component_name = current_fw['Name']
|
||||
current_version = current_fw['Version']
|
||||
|
||||
# 최신 버전 찾기 (컴포넌트명 매칭)
|
||||
latest = None
|
||||
for lv in latest_versions:
|
||||
if lv.component_name.lower() in component_name.lower():
|
||||
# 서버 모델 확인
|
||||
if not lv.server_model or lv.server_model == server.model:
|
||||
latest = lv
|
||||
break
|
||||
|
||||
# 비교
|
||||
comparison = FirmwareComparisonResult(
|
||||
component_name=component_name,
|
||||
current_version=current_version,
|
||||
latest_version=latest.latest_version if latest else None
|
||||
)
|
||||
|
||||
result = comparison.to_dict()
|
||||
|
||||
# 추가 정보
|
||||
if latest:
|
||||
result['latest_info'] = {
|
||||
'release_date': latest.release_date,
|
||||
'download_url': latest.download_url,
|
||||
'file_name': latest.file_name,
|
||||
'is_critical': latest.is_critical,
|
||||
'notes': latest.notes
|
||||
}
|
||||
|
||||
comparisons.append(result)
|
||||
|
||||
# 통계
|
||||
total = len(comparisons)
|
||||
outdated = len([c for c in comparisons if c['status'] == 'outdated'])
|
||||
latest_count = len([c for c in comparisons if c['status'] == 'latest'])
|
||||
unknown = len([c for c in comparisons if c['status'] == 'unknown'])
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'server': {
|
||||
'id': server.id,
|
||||
'name': server.name,
|
||||
'model': server.model
|
||||
},
|
||||
'comparisons': comparisons,
|
||||
'summary': {
|
||||
'total': total,
|
||||
'outdated': outdated,
|
||||
'latest': latest_count,
|
||||
'unknown': unknown
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
@idrac_bp.route('/api/servers/firmware/compare-multi', methods=['POST'])
|
||||
def compare_multi_servers_firmware():
|
||||
"""
|
||||
여러 서버의 펌웨어 버전 비교
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
server_ids = data.get('server_ids', [])
|
||||
|
||||
if not server_ids:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '서버를 선택하세요'
|
||||
})
|
||||
|
||||
results = []
|
||||
|
||||
for server_id in server_ids:
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 각 서버 비교
|
||||
client = DellRedfishClient(server.ip_address, server.username, server.password)
|
||||
current_inventory = client.get_firmware_inventory()
|
||||
|
||||
latest_versions = FirmwareVersion.query.filter_by(is_active=True).all()
|
||||
|
||||
outdated_count = 0
|
||||
outdated_items = []
|
||||
|
||||
for current_fw in current_inventory:
|
||||
component_name = current_fw['Name']
|
||||
current_version = current_fw['Version']
|
||||
|
||||
# 최신 버전 찾기
|
||||
for lv in latest_versions:
|
||||
if lv.component_name.lower() in component_name.lower():
|
||||
comparison = FirmwareComparisonResult(
|
||||
component_name=component_name,
|
||||
current_version=current_version,
|
||||
latest_version=lv.latest_version
|
||||
)
|
||||
|
||||
if comparison.status == 'outdated':
|
||||
outdated_count += 1
|
||||
outdated_items.append({
|
||||
'component': component_name,
|
||||
'current': current_version,
|
||||
'latest': lv.latest_version
|
||||
})
|
||||
break
|
||||
|
||||
results.append({
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'outdated_count': outdated_count,
|
||||
'outdated_items': outdated_items,
|
||||
'status': 'needs_update' if outdated_count > 0 else 'up_to_date'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
results.append({
|
||||
'server_id': server.id,
|
||||
'server_name': server.name,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'results': results
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
})
|
||||
|
||||
|
||||
# ========================================
|
||||
# 초기 데이터 생성 함수
|
||||
# ========================================
|
||||
|
||||
def init_firmware_versions():
|
||||
"""초기 펌웨어 버전 데이터 생성"""
|
||||
initial_versions = [
|
||||
{
|
||||
'component_name': 'BIOS',
|
||||
'component_type': 'Firmware',
|
||||
'vendor': 'Dell',
|
||||
'server_model': 'PowerEdge R750',
|
||||
'latest_version': '2.15.0',
|
||||
'release_date': '2024-01-15',
|
||||
'notes': 'PowerEdge R750 최신 BIOS'
|
||||
},
|
||||
{
|
||||
'component_name': 'iDRAC',
|
||||
'component_type': 'Firmware',
|
||||
'vendor': 'Dell',
|
||||
'latest_version': '6.10.30.00',
|
||||
'release_date': '2024-02-20',
|
||||
'notes': 'iDRAC9 최신 펌웨어 (모든 모델 공용)'
|
||||
},
|
||||
{
|
||||
'component_name': 'PERC H755',
|
||||
'component_type': 'Firmware',
|
||||
'vendor': 'Dell',
|
||||
'server_model': 'PowerEdge R750',
|
||||
'latest_version': '25.5.9.0001',
|
||||
'release_date': '2024-01-10',
|
||||
'notes': 'PERC H755 RAID 컨트롤러'
|
||||
},
|
||||
{
|
||||
'component_name': 'BIOS',
|
||||
'component_type': 'Firmware',
|
||||
'vendor': 'Dell',
|
||||
'server_model': 'PowerEdge R640',
|
||||
'latest_version': '2.19.2',
|
||||
'release_date': '2024-02-01',
|
||||
'notes': 'PowerEdge R640 최신 BIOS'
|
||||
},
|
||||
{
|
||||
'component_name': 'CPLD',
|
||||
'component_type': 'Firmware',
|
||||
'vendor': 'Dell',
|
||||
'latest_version': '1.0.6',
|
||||
'release_date': '2023-12-15',
|
||||
'notes': '시스템 보드 CPLD (14G/15G 공용)'
|
||||
},
|
||||
]
|
||||
|
||||
for data in initial_versions:
|
||||
# 중복 체크
|
||||
existing = FirmwareVersion.query.filter_by(
|
||||
component_name=data['component_name'],
|
||||
server_model=data.get('server_model')
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
version = FirmwareVersion(**data)
|
||||
db.session.add(version)
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
print("✓ 초기 펌웨어 버전 데이터 생성 완료")
|
||||
except:
|
||||
db.session.rollback()
|
||||
print("⚠ 초기 데이터 생성 중 오류 (이미 있을 수 있음)")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user