Initial project upload
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
||||
.env
|
||||
*.env
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
|
||||
backend/instance/*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
data/temp/
|
||||
data/system/archive/
|
||||
data/system/backup/
|
||||
data/system/logs/
|
||||
data/system/mac_backup/
|
||||
data/repository/
|
||||
data/nfs/
|
||||
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
*.log
|
||||
@@ -0,0 +1,104 @@
|
||||
이 프로젝트에서 파일 관리 페이지의 UI/UX를 리디자인해줘.
|
||||
|
||||
목표:
|
||||
현재 페이지를 "도구 영역"과 "결과 확인 영역"으로 명확히 분리하고,
|
||||
탭 / KPI / 필터 / 검색 / 빈 상태가 한눈에 이해되도록 구조를 개선해줘.
|
||||
기존 기능은 유지하고, 마크업 구조와 스타일 중심으로 정리해줘.
|
||||
|
||||
현재 화면 요약:
|
||||
- 상단에 "파일 관리 도구" 카드가 있고, ZIP / 백업 / QUICK MOVE(MAC, GUID, GPU) 기능이 있음
|
||||
- 그 아래에 "FILE WORKSPACE" 영역이 있고, "처리된 파일 / 백업 및 저장소" 탭이 있음
|
||||
- KPI 카드로 MAC 파일 / GUID 파일 / GPU 파일 수가 보임
|
||||
- 검색창이 따로 있음
|
||||
- 하단에는 파일 리스트 대신 empty state가 보이며 "처리된 파일이 없습니다"와 "IP 처리로 이동" 버튼이 있음
|
||||
|
||||
현재 문제:
|
||||
1. 도구 영역과 결과 확인 영역의 구분은 생겼지만, 사용자의 행동 흐름 기준으로는 아직 약간 분산되어 보임
|
||||
2. 탭 / KPI / 검색 / 설명 문구의 위계가 조금 애매함
|
||||
3. KPI 카드가 정보 표시는 되지만 클릭 가능한지, 필터와 연결되는지 직관성이 약함
|
||||
4. 검색창이 현재 보는 범위와 강하게 연결되어 보이지 않음
|
||||
5. empty state는 나쁘지 않지만 더 명확한 안내와 액션 구조로 정리 가능함
|
||||
|
||||
원하는 구조:
|
||||
1. 상단 카드 = 파일 관리 도구
|
||||
- ZIP
|
||||
- 백업
|
||||
- QUICK MOVE(MAC / GUID / GPU)
|
||||
- 이 영역은 "작업 실행" 용도라는 것이 명확해야 함
|
||||
|
||||
2. 하단 메인 카드 = 결과 확인 워크스페이스
|
||||
- 상단 헤더: 페이지 제목 + 짧은 설명
|
||||
- 같은 줄 또는 바로 아래: 메인 탭
|
||||
- 처리된 파일
|
||||
- 백업 및 저장소
|
||||
- 그 아래: KPI 카드
|
||||
- MAC
|
||||
- GUID
|
||||
- GPU
|
||||
- 그 아래: 필터 + 검색
|
||||
- 예: 전체 / MAC / GUID / GPU
|
||||
- 검색창은 현재 활성 탭/필터 기준 검색이라는 점이 드러나야 함
|
||||
- 마지막: 데이터 리스트 또는 empty state
|
||||
|
||||
원하는 UX 방향:
|
||||
- 사용자가 첫눈에 "여기는 결과 확인 영역"이라는 점을 이해할 수 있게 해줘
|
||||
- 탭 → KPI → 필터/검색 → 결과 목록 순서로 자연스럽게 읽히게 해줘
|
||||
- KPI 카드는 클릭하거나 선택 가능한 카드처럼 보이게 해줘
|
||||
- 검색창 placeholder를 더 구체적으로 바꿔줘
|
||||
예: "처리된 파일명, MAC, GUID 검색"
|
||||
- empty state는 더 친절하고 명확하게 보여줘
|
||||
예:
|
||||
- 아이콘
|
||||
- 제목
|
||||
- 설명
|
||||
- 1차 액션 버튼
|
||||
|
||||
디자인 방향:
|
||||
- Bootstrap 기반 유지
|
||||
- 지금보다 조금 더 정돈된 관리자 페이지 느낌
|
||||
- 과한 장식보다 실무형 UI
|
||||
- 카드/버튼/탭의 시각적 계층을 명확히
|
||||
- spacing, alignment, radius, hover state를 정리
|
||||
- 색상은 기존 의미를 유지
|
||||
- MAC = blue 계열
|
||||
- GUID = green 계열
|
||||
- GPU = red/pink 계열
|
||||
|
||||
수정 범위:
|
||||
- templates/index.html
|
||||
- static/ css
|
||||
- 필요 시 js 최소 수정
|
||||
- Flask 백엔드 로직, 라우트, 데이터 구조는 수정하지 말 것
|
||||
|
||||
반드시 유지:
|
||||
- 기존 Jinja 변수명
|
||||
- 기존 form submit 방식
|
||||
- 기존 endpoint 이름
|
||||
- 기존 버튼 동작 로직
|
||||
- 기존 데이터 바인딩 구조
|
||||
|
||||
금지사항:
|
||||
- 새 라이브러리 추가 금지
|
||||
- 전체 페이지를 처음부터 갈아엎는 전면 재작성 금지
|
||||
- 백엔드 로직 변경 금지
|
||||
- 기존 기능 삭제 금지
|
||||
|
||||
완료조건:
|
||||
1. 도구 영역과 결과 영역이 시각적으로 명확히 분리될 것
|
||||
2. 결과 영역은 "탭 → KPI → 필터/검색 → 결과" 흐름이 분명할 것
|
||||
3. KPI 카드가 단순 숫자 표시가 아니라 탐색 요소처럼 보일 것
|
||||
4. 검색창이 현재 탭/필터와 연결되어 보일 것
|
||||
5. empty state가 더 직관적일 것
|
||||
6. 기존 기능은 그대로 동작할 것
|
||||
7. 모바일/좁은 화면에서도 크게 깨지지 않을 것
|
||||
|
||||
작업 방식:
|
||||
1. 먼저 관련 파일을 읽고 현재 구조를 짧게 요약
|
||||
2. 변경 계획을 5~8줄로 먼저 제시
|
||||
3. 그 다음 최소 diff 원칙으로 수정
|
||||
4. 마지막에 변경 파일 목록과 핵심 변경점, 확인할 테스트 포인트를 정리
|
||||
|
||||
가능하면 다음도 반영해줘:
|
||||
- KPI 카드 클릭 시 해당 필터가 활성화되도록 연결할 수 있으면 연결
|
||||
- empty state 버튼 문구와 보조 문구를 더 자연스럽게 개선
|
||||
- 검색창 라벨/placeholder를 활성 탭에 따라 달리 보이게 할 수 있으면 반영
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_socketio import SocketIO
|
||||
from flask_wtf import CSRFProtect
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from config import Config
|
||||
from backend.models.user import db, load_user
|
||||
from backend.routes import register_routes
|
||||
from backend.services.logger import setup_logging
|
||||
from backend.services import watchdog_handler
|
||||
|
||||
# 텔레그램 서비스 (별도 모듈)에서 가져옴
|
||||
from backend.services.telegram_bot_service import run_polling as telegram_run_polling
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# .env 파일 로드 (환경변수 우선순위 보장)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
load_dotenv()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 템플릿/정적 경로를 파일 위치 기준으로 안전하게 설정
|
||||
# structure: <project_root>/backend/templates, <project_root>/backend/static
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
TEMPLATE_DIR = (BASE_DIR / "backend" / "templates").resolve()
|
||||
STATIC_DIR = (BASE_DIR / "backend" / "static").resolve()
|
||||
|
||||
# Flask 애플리케이션 생성
|
||||
app = Flask(__name__, template_folder=str(TEMPLATE_DIR), static_folder=str(STATIC_DIR))
|
||||
app.config.from_object(Config)
|
||||
|
||||
# 로그 설정
|
||||
setup_logging(app)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# CSRF 보호 + 템플릿에서 {{ csrf_token() }} 사용 가능하게 주입
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
csrf = CSRFProtect()
|
||||
csrf.init_app(app)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# ProxyFix: Nginx/NPM 등 리버스 프록시 뒤에서 실행 시 헤더 신뢰
|
||||
# (HTTPS 인식, 올바른 IP/Scheme 파악으로 CSRF/세션 문제 해결)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_csrf():
|
||||
try:
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
return dict(csrf_token=generate_csrf)
|
||||
except Exception:
|
||||
# Flask-WTF 미설치/에러 시에도 앱이 뜨도록 방어
|
||||
return dict(csrf_token=lambda: "")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# SocketIO: Windows 기본 threading, Linux는 eventlet 설치 시 eventlet 사용
|
||||
# 환경변수 SOCKETIO_ASYNC_MODE 로 강제 지정 가능 ("threading"/"eventlet"/"gevent"/"auto")
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
async_mode = (app.config.get("SOCKETIO_ASYNC_MODE") or "threading").lower()
|
||||
if async_mode == "auto":
|
||||
async_mode = "threading"
|
||||
|
||||
if async_mode == "eventlet":
|
||||
# Windows에선 eventlet 비권장, Linux에서만 시도
|
||||
if platform.system() != "Windows":
|
||||
try:
|
||||
import eventlet # type: ignore
|
||||
eventlet.monkey_patch()
|
||||
except Exception:
|
||||
async_mode = "threading" # 폴백
|
||||
else:
|
||||
async_mode = "threading"
|
||||
|
||||
socketio = SocketIO(app, cors_allowed_origins="*", async_mode=async_mode)
|
||||
|
||||
# watchdog에서 socketio 사용
|
||||
watchdog_handler.socketio = socketio
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# DB / 마이그레이션
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
app.logger.debug("DB URI = %s", app.config.get("SQLALCHEMY_DATABASE_URI"))
|
||||
db.init_app(app)
|
||||
Migrate(app, db)
|
||||
|
||||
# (선택) 개발 편의용: 테이블 자동 부트스트랩
|
||||
# 환경변수 AUTO_BOOTSTRAP_DB=true 일 때만 동작 (운영에서는 flask db upgrade 사용 권장)
|
||||
if os.getenv("AUTO_BOOTSTRAP_DB", "false").lower() == "true":
|
||||
from sqlalchemy import inspect
|
||||
|
||||
with app.app_context():
|
||||
insp = inspect(db.engine)
|
||||
if "user" not in insp.get_table_names():
|
||||
db.create_all()
|
||||
app.logger.info("DB bootstrap: created tables via create_all()")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Login
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = "auth.login"
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def _load_user(user_id: str):
|
||||
return load_user(user_id)
|
||||
|
||||
|
||||
# 라우트 등록 (Blueprints 등)
|
||||
register_routes(app, socketio)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 텔레그램 봇 폴링 서비스 (중복 실행 방지 포함)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
_bot_socket_lock = None
|
||||
|
||||
def start_telegram_bot_polling() -> None:
|
||||
"""텔레그램 봇 폴링을 백그라운드 스레드로 시작 (TCP 소켓 락으로 중복 방지)"""
|
||||
import threading
|
||||
import socket
|
||||
|
||||
global _bot_socket_lock
|
||||
|
||||
if _bot_socket_lock:
|
||||
return
|
||||
|
||||
app.logger.debug("🔒 봇 중복 실행 방지 락(TCP:50000) 획득 시도...")
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind(("127.0.0.1", 50000))
|
||||
s.listen(1)
|
||||
_bot_socket_lock = s
|
||||
app.logger.debug("🔒 락 획득 성공! 봇 폴링 스레드를 시작합니다.")
|
||||
except OSError:
|
||||
app.logger.warning("⛔ 락 획득 실패: 이미 다른 프로세스(또는 좀비 프로세스)가 포트 50000을 점유 중입니다. 봇 폴링을 건너뜁니다.")
|
||||
return
|
||||
|
||||
def _runner():
|
||||
try:
|
||||
telegram_run_polling(app)
|
||||
except Exception as e:
|
||||
app.logger.error("텔레그램 봇 폴링 서비스 오류: %s", e)
|
||||
|
||||
polling_thread = threading.Thread(target=_runner, daemon=True)
|
||||
polling_thread.start()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 텔레그램 봇 폴링 자동 시작
|
||||
# Flask 앱이 초기화되면 자동으로 봇 폴링 시작
|
||||
# 주의: Flask 리로더(Debug 모드) 사용 시 메인/워커 프로세스 중복 실행 방지
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 1. 리로더의 워커 프로세스인 경우 (WERKZEUG_RUN_MAIN = "true")
|
||||
# 2. 또는 디버그 모드가 꺼진 경우 (Production)
|
||||
if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not app.config.get("DEBUG"):
|
||||
start_telegram_bot_polling()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 엔트리포인트
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
host = os.getenv("FLASK_HOST", "0.0.0.0")
|
||||
port = int(os.getenv("FLASK_PORT", 6050))
|
||||
debug = os.getenv("FLASK_DEBUG", "true").lower() == "true"
|
||||
|
||||
# python app.py로 직접 실행 시(use_reloader=False)에는 위 조건문에서 실행되지 않을 수 있으므로
|
||||
# 여기서 명시적으로 실행 (중복 실행 방지 플래그가 있어 안전함)
|
||||
start_telegram_bot_polling()
|
||||
|
||||
socketio.run(app, host=host, port=port, debug=debug, allow_unsafe_werkzeug=True, 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)
|
||||
@@ -0,0 +1,116 @@
|
||||
# backend/forms/auth_forms.py (refactor)
|
||||
from __future__ import annotations
|
||||
import re
|
||||
import unicodedata
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SubmitField, BooleanField
|
||||
from wtforms.validators import (
|
||||
DataRequired, Length, Email, EqualTo, ValidationError, Regexp
|
||||
)
|
||||
from backend.models.user import User
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 공통 필터/유틸
|
||||
def strip_filter(x: str | None) -> str | None:
|
||||
return x.strip() if isinstance(x, str) else x
|
||||
|
||||
def lower_strip(x: str | None) -> str | None:
|
||||
return x.strip().lower() if isinstance(x, str) else x
|
||||
|
||||
def nfc_korean(x: str | None) -> str | None:
|
||||
if not isinstance(x, str):
|
||||
return x
|
||||
# 한글 이름 등 유니코드 정규화 (NFC)
|
||||
return unicodedata.normalize("NFC", x.strip())
|
||||
|
||||
# 비밀번호 정책: 8~64자, 대문자/소문자/숫자/특수문자 각 1개 이상
|
||||
password_policy_validators = [
|
||||
Length(min=8, max=64, message="비밀번호는 8~64자여야 합니다."),
|
||||
Regexp(r".*[A-Z].*", message="비밀번호에 대문자가 1자 이상 포함되어야 합니다."),
|
||||
Regexp(r".*[a-z].*", message="비밀번호에 소문자가 1자 이상 포함되어야 합니다."),
|
||||
Regexp(r".*\d.*", message="비밀번호에 숫자가 1자 이상 포함되어야 합니다."),
|
||||
Regexp(r".*[^A-Za-z0-9].*", message="비밀번호에 특수문자가 1자 이상 포함되어야 합니다."),
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
class RegistrationForm(FlaskForm):
|
||||
username = StringField(
|
||||
"이름",
|
||||
filters=[nfc_korean],
|
||||
validators=[
|
||||
DataRequired(message="이름을 입력해주세요."),
|
||||
Length(min=2, max=20, message="이름은 2~20자 사이여야 합니다."),
|
||||
],
|
||||
render_kw={
|
||||
"placeholder": "이름 (한글만 허용)",
|
||||
"autocomplete": "name",
|
||||
"autocapitalize": "off",
|
||||
"autocorrect": "off",
|
||||
"spellcheck": "false",
|
||||
},
|
||||
)
|
||||
|
||||
email = StringField(
|
||||
"이메일",
|
||||
filters=[lower_strip],
|
||||
validators=[
|
||||
DataRequired(message="이메일을 입력해주세요."),
|
||||
Email(message="유효한 이메일을 입력하세요."),
|
||||
],
|
||||
render_kw={
|
||||
"placeholder": "예: user@example.com",
|
||||
"autocomplete": "email",
|
||||
"inputmode": "email",
|
||||
},
|
||||
)
|
||||
|
||||
password = PasswordField(
|
||||
"비밀번호",
|
||||
validators=[DataRequired(message="비밀번호를 입력해주세요."), *password_policy_validators],
|
||||
render_kw={"placeholder": "비밀번호", "autocomplete": "new-password"},
|
||||
)
|
||||
|
||||
confirm_password = PasswordField(
|
||||
"비밀번호 확인",
|
||||
validators=[
|
||||
DataRequired(message="비밀번호 확인을 입력해주세요."),
|
||||
EqualTo("password", message="비밀번호가 일치하지 않습니다."),
|
||||
],
|
||||
render_kw={"placeholder": "비밀번호 다시 입력", "autocomplete": "new-password"},
|
||||
)
|
||||
|
||||
submit = SubmitField("회원가입")
|
||||
|
||||
def validate_username(self, field):
|
||||
# 한글만 허용(2~20자) – 기존 로직 유지
|
||||
if not re.fullmatch(r"[가-힣]{2,20}", field.data or ""):
|
||||
raise ValidationError("이름은 한글로만 2~20자 입력 가능합니다.")
|
||||
# 중복 체크
|
||||
user = User.query.filter_by(username=field.data).first()
|
||||
if user:
|
||||
raise ValidationError("이미 사용 중인 이름입니다.")
|
||||
|
||||
def validate_email(self, field):
|
||||
# 이메일은 소문자 비교(필터로 이미 소문자화)
|
||||
user = User.query.filter_by(email=field.data).first()
|
||||
if user:
|
||||
raise ValidationError("이미 등록된 이메일입니다.")
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
email = StringField(
|
||||
"이메일",
|
||||
filters=[lower_strip],
|
||||
validators=[DataRequired(message="이메일을 입력해주세요."), Email(message="유효한 이메일을 입력하세요.")],
|
||||
render_kw={"placeholder": "이메일 주소", "autocomplete": "username", "inputmode": "email"},
|
||||
)
|
||||
|
||||
password = PasswordField(
|
||||
"비밀번호",
|
||||
validators=[DataRequired(message="비밀번호를 입력해주세요.")],
|
||||
render_kw={"placeholder": "비밀번호", "autocomplete": "current-password"},
|
||||
)
|
||||
|
||||
remember = BooleanField("로그인 유지")
|
||||
submit = SubmitField("로그인")
|
||||
@@ -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,134 @@
|
||||
"""
|
||||
펌웨어 버전 관리 모델
|
||||
backend/models/firmware_version.py
|
||||
"""
|
||||
|
||||
from backend.models.user import db
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
|
||||
|
||||
|
||||
class FirmwareVersion(db.Model):
|
||||
"""펌웨어 최신 버전 정보 모델"""
|
||||
__tablename__ = 'firmware_versions'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
# 컴포넌트 정보
|
||||
component_name = Column(String(100), nullable=False) # BIOS, iDRAC, PERC 등
|
||||
component_type = Column(String(50)) # Firmware, Driver 등
|
||||
vendor = Column(String(50)) # Dell, Intel, Broadcom 등
|
||||
|
||||
# 서버 모델 (선택적)
|
||||
server_model = Column(String(100)) # PowerEdge R750, R640 등 (비어있으면 모든 모델)
|
||||
|
||||
# 버전 정보
|
||||
latest_version = Column(String(50), nullable=False) # 최신 버전
|
||||
release_date = Column(String(20)) # 릴리즈 날짜
|
||||
|
||||
# 다운로드 정보
|
||||
download_url = Column(String(500)) # DUP 파일 다운로드 URL
|
||||
file_name = Column(String(200)) # DUP 파일명
|
||||
file_size_mb = Column(Integer) # 파일 크기 (MB)
|
||||
|
||||
# 메타 정보
|
||||
notes = Column(Text) # 비고 (버전 변경 사항 등)
|
||||
is_critical = Column(Boolean, default=False) # 중요 업데이트 여부
|
||||
is_active = Column(Boolean, default=True) # 활성화 여부
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
"""딕셔너리로 변환"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'component_name': self.component_name,
|
||||
'component_type': self.component_type,
|
||||
'vendor': self.vendor,
|
||||
'server_model': self.server_model,
|
||||
'latest_version': self.latest_version,
|
||||
'release_date': self.release_date,
|
||||
'download_url': self.download_url,
|
||||
'file_name': self.file_name,
|
||||
'file_size_mb': self.file_size_mb,
|
||||
'notes': self.notes,
|
||||
'is_critical': self.is_critical,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<FirmwareVersion {self.component_name} {self.latest_version}>'
|
||||
|
||||
|
||||
class FirmwareComparisonResult:
|
||||
"""펌웨어 버전 비교 결과"""
|
||||
|
||||
def __init__(self, component_name, current_version, latest_version=None):
|
||||
self.component_name = component_name
|
||||
self.current_version = current_version
|
||||
self.latest_version = latest_version
|
||||
self.status = self._compare_versions()
|
||||
self.recommendation = self._get_recommendation()
|
||||
|
||||
def _compare_versions(self):
|
||||
"""버전 비교"""
|
||||
if not self.latest_version:
|
||||
return 'unknown' # 최신 버전 정보 없음
|
||||
|
||||
if self.current_version == self.latest_version:
|
||||
return 'latest' # 최신
|
||||
|
||||
# 버전 비교 (단순 문자열 비교보다 정교하게)
|
||||
if self._is_older(self.current_version, self.latest_version):
|
||||
return 'outdated' # 업데이트 필요
|
||||
else:
|
||||
return 'unknown' # 판단 불가
|
||||
|
||||
def _is_older(self, current, latest):
|
||||
"""
|
||||
버전 비교 (간단한 버전 비교)
|
||||
예: 2.10.0 < 2.15.0
|
||||
"""
|
||||
try:
|
||||
# 버전 문자열을 숫자 리스트로 변환
|
||||
current_parts = [int(x) for x in current.replace('-', '.').split('.') if x.isdigit()]
|
||||
latest_parts = [int(x) for x in latest.replace('-', '.').split('.') if x.isdigit()]
|
||||
|
||||
# 길이 맞추기
|
||||
max_len = max(len(current_parts), len(latest_parts))
|
||||
current_parts += [0] * (max_len - len(current_parts))
|
||||
latest_parts += [0] * (max_len - len(latest_parts))
|
||||
|
||||
# 비교
|
||||
for c, l in zip(current_parts, latest_parts):
|
||||
if c < l:
|
||||
return True
|
||||
elif c > l:
|
||||
return False
|
||||
|
||||
return False # 같음
|
||||
|
||||
except:
|
||||
# 비교 실패 시 문자열 비교
|
||||
return current < latest
|
||||
|
||||
def _get_recommendation(self):
|
||||
"""권장 사항"""
|
||||
if self.status == 'outdated':
|
||||
return f'업데이트 권장: {self.current_version} → {self.latest_version}'
|
||||
elif self.status == 'latest':
|
||||
return '최신 버전'
|
||||
else:
|
||||
return '버전 정보 확인 필요'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'component_name': self.component_name,
|
||||
'current_version': self.current_version,
|
||||
'latest_version': self.latest_version,
|
||||
'status': self.status,
|
||||
'recommendation': self.recommendation
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Dell iDRAC 서버 관리 모델
|
||||
backend/models/idrac_server.py
|
||||
"""
|
||||
|
||||
from backend.models.user import db
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
||||
|
||||
|
||||
class IdracServer(db.Model):
|
||||
"""iDRAC 서버 정보 모델"""
|
||||
__tablename__ = 'idrac_servers'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(100), nullable=False) # 서버명
|
||||
ip_address = Column(String(50), nullable=False, unique=True) # iDRAC IP
|
||||
username = Column(String(50), default='root') # iDRAC 사용자명
|
||||
password = Column(String(200), nullable=False) # 비밀번호 (암호화 권장)
|
||||
|
||||
# 분류 정보
|
||||
group_name = Column(String(100)) # 그룹명 (고객사, 프로젝트명)
|
||||
location = Column(String(100)) # 위치
|
||||
model = Column(String(100)) # 서버 모델
|
||||
|
||||
# 상태 정보
|
||||
status = Column(String(20), default='registered') # registered, online, offline, updating
|
||||
last_connected = Column(DateTime) # 마지막 연결 시간
|
||||
last_updated = Column(DateTime) # 마지막 업데이트 시간
|
||||
|
||||
# 펌웨어 정보
|
||||
current_bios = Column(String(50)) # 현재 BIOS 버전
|
||||
target_bios = Column(String(50)) # 목표 BIOS 버전
|
||||
|
||||
# 메타 정보
|
||||
notes = Column(String(500)) # 비고
|
||||
is_active = Column(Boolean, default=True) # 활성화 여부
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
"""딕셔너리로 변환"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'ip_address': self.ip_address,
|
||||
'username': self.username,
|
||||
'group_name': self.group_name,
|
||||
'location': self.location,
|
||||
'model': self.model,
|
||||
'status': self.status,
|
||||
'last_connected': self.last_connected.isoformat() if self.last_connected else None,
|
||||
'last_updated': self.last_updated.isoformat() if self.last_updated else None,
|
||||
'current_bios': self.current_bios,
|
||||
'target_bios': self.target_bios,
|
||||
'notes': self.notes,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<IdracServer {self.name} ({self.ip_address})>'
|
||||
@@ -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')
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.models.user import db
|
||||
|
||||
|
||||
class SystemSetting(db.Model):
|
||||
__tablename__ = "system_setting"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from backend.models.user import db
|
||||
|
||||
class TelegramBot(db.Model):
|
||||
__tablename__ = 'telegram_bots'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False) # 봇 식별 이름 (예: 알림용, 경고용)
|
||||
token = db.Column(db.String(200), nullable=False)
|
||||
chat_id = db.Column(db.String(100), nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
# 알림 유형: 콤마로 구분 (예: "auth,activity,system")
|
||||
notification_types = db.Column(db.String(255), default="auth,activity,system")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'token': self.token,
|
||||
'chat_id': self.chat_id,
|
||||
'is_active': self.is_active,
|
||||
'description': self.description,
|
||||
'notification_types': self.notification_types
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy import String, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from werkzeug.security import check_password_hash as wz_check_password_hash
|
||||
import logging
|
||||
|
||||
# passlib: Argon2id 기본, scrypt/pbkdf2는 검증만 (점진 마이그레이션)
|
||||
from passlib.context import CryptContext
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
# Argon2id를 기본으로 사용하고, scrypt/pbkdf2_sha256은 검증만 허용(=deprecated)
|
||||
# 로그인에 성공하면 자동으로 Argon2id로 재해시 저장합니다.
|
||||
pwd_ctx = CryptContext(
|
||||
schemes=["argon2", "scrypt", "pbkdf2_sha256"],
|
||||
default="argon2",
|
||||
deprecated=["scrypt", "pbkdf2_sha256"],
|
||||
# Argon2id 파라미터 (기본도 충분하지만 서비스 보안수준에 맞춰 조정 가능)
|
||||
# time_cost: 2~4, memory_cost: 64~256 MiB 권장 범위
|
||||
argon2__type="ID",
|
||||
argon2__time_cost=3,
|
||||
argon2__memory_cost=102400, # 100 MiB
|
||||
argon2__parallelism=8,
|
||||
# PBKDF2 라운드 상향 (레거시 검증용)
|
||||
pbkdf2_sha256__rounds=300_000,
|
||||
)
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
__tablename__ = "user"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True)
|
||||
password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# 가입 승인 관련 필드
|
||||
is_approved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
approval_token: Mapped[Optional[str]] = mapped_column(String(100), unique=True, nullable=True)
|
||||
|
||||
# ── 유틸 메서드
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<User id={self.id} email={self.email} active={self.is_active}>"
|
||||
|
||||
# 신규 저장/변경 시 항상 Argon2id로 해시
|
||||
def set_password(self, password: str) -> None:
|
||||
self.password = pwd_ctx.hash(password)
|
||||
|
||||
# 혼합 검증: scrypt/pbkdf2/argon2 모두 검증 가능
|
||||
# 검증 성공 + 레거시 스킴이면 Argon2id로 즉시 재해시 & 커밋
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""
|
||||
- 우선 저장된 해시의 '형식'을 보고 검증기를 선택한다.
|
||||
* 'scrypt:' 또는 'pbkdf2:'로 시작하면 → Werkzeug 검증
|
||||
* 그 외 → passlib(CryptContext) 검증
|
||||
- 검증에 성공했고 현재 해시가 Argon2가 아니거나(passlib가 needs_update True),
|
||||
Werkzeug 형식(scrypt/pbkdf2)이라면 즉시 Argon2id로 재해시 저장한다.
|
||||
"""
|
||||
raw = self.password or ""
|
||||
|
||||
ok = False
|
||||
used_werkzeug = False
|
||||
|
||||
try:
|
||||
# 1) Werkzeug 포맷 감지 (예: 'scrypt:32768:8:1$...', 'pbkdf2:sha256:260000$...')
|
||||
if raw.startswith("scrypt:") or raw.startswith("pbkdf2:"):
|
||||
used_werkzeug = True
|
||||
ok = wz_check_password_hash(raw, password) # hashlib 기반 → 형식 그대로 검증
|
||||
else:
|
||||
# 2) passlib 포맷(argon2/$scrypt$/pbkdf2_sha256) 시도
|
||||
ok = pwd_ctx.verify(password, raw)
|
||||
except Exception as e:
|
||||
logging.warning("password verify failed: %s", e)
|
||||
ok = False
|
||||
|
||||
if not ok:
|
||||
return False
|
||||
|
||||
# ── 여기까지 왔으면 검증 성공. 필요 시 Argon2id로 즉시 업그레이드.
|
||||
try:
|
||||
need_upgrade = False
|
||||
|
||||
if used_werkzeug:
|
||||
# Werkzeug 형식(scrypt/pbkdf2)은 우리 정책상 모두 Argon2로 마이그레이션
|
||||
need_upgrade = True
|
||||
else:
|
||||
# passlib가 판단하는 업그레이드 필요 여부(파라미터/알고리즘 기준)
|
||||
need_upgrade = pwd_ctx.needs_update(raw)
|
||||
|
||||
if need_upgrade:
|
||||
self.password = pwd_ctx.hash(password) # Argon2id 기본
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
# 업그레이드 실패해도 로그인 자체는 성공시킨다(다음 로그인 때 재시도)
|
||||
logging.warning("password rehash (argon2) failed: %s", e)
|
||||
db.session.rollback()
|
||||
|
||||
return True
|
||||
|
||||
# Flask-Login 호환 (UserMixin 기본 get_id 사용 가능하지만 명시)
|
||||
def get_id(self) -> str: # pragma: no cover
|
||||
return str(self.id)
|
||||
|
||||
# ── 조회 헬퍼
|
||||
@staticmethod
|
||||
def find_by_email(email: Optional[str]) -> Optional["User"]:
|
||||
q = (email or "").strip().lower()
|
||||
if not q:
|
||||
return None
|
||||
try:
|
||||
return User.query.filter_by(email=q).first()
|
||||
except Exception as e:
|
||||
logging.error(f"User find_by_email error: {e}")
|
||||
db.session.rollback()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_by_username(username: Optional[str]) -> Optional["User"]:
|
||||
q = (username or "").strip()
|
||||
if not q:
|
||||
return None
|
||||
try:
|
||||
return User.query.filter_by(username=q).first()
|
||||
except Exception as e:
|
||||
logging.error(f"User find_by_username error: {e}")
|
||||
db.session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
# Flask-Login user_loader (SQLAlchemy 2.0 방식)
|
||||
def load_user(user_id: str) -> Optional[User]: # pragma: no cover
|
||||
try:
|
||||
return db.session.get(User, int(user_id))
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
from flask import Flask
|
||||
from .home import register_home_routes
|
||||
from .auth import register_auth_routes
|
||||
from .admin import register_admin_routes
|
||||
from .main import register_main_routes
|
||||
from .xml import register_xml_routes
|
||||
from .utilities import register_util_routes, utils_bp
|
||||
from .file_view import register_file_view
|
||||
from .jobs import register_jobs_routes
|
||||
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:
|
||||
"""블루프린트 일괄 등록. socketio는 main 라우트에서만 사용."""
|
||||
register_home_routes(app)
|
||||
register_auth_routes(app)
|
||||
register_admin_routes(app)
|
||||
register_main_routes(app, socketio)
|
||||
register_xml_routes(app)
|
||||
app.register_blueprint(utils_bp, url_prefix="/utils")
|
||||
register_file_view(app)
|
||||
register_jobs_routes(app)
|
||||
register_idrac_routes(app)
|
||||
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}")
|
||||
@@ -0,0 +1,373 @@
|
||||
# backend/routes/admin.py
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
render_template,
|
||||
redirect,
|
||||
url_for,
|
||||
flash,
|
||||
abort,
|
||||
request,
|
||||
current_app,
|
||||
)
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from backend.models.user import User, db
|
||||
from backend.models.telegram_bot import TelegramBot
|
||||
from backend.models.system_setting import SystemSetting
|
||||
try:
|
||||
from telegram import Bot
|
||||
except ImportError:
|
||||
Bot = None
|
||||
|
||||
admin_bp = Blueprint("admin", __name__)
|
||||
|
||||
|
||||
def get_system_setting(key: str, default: str = "") -> str:
|
||||
setting = db.session.get(SystemSetting, key)
|
||||
return setting.value if setting else default
|
||||
|
||||
|
||||
def set_system_setting(key: str, value: str) -> None:
|
||||
setting = db.session.get(SystemSetting, key)
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
db.session.add(SystemSetting(key=key, value=value))
|
||||
|
||||
|
||||
# Blueprint 등록
|
||||
def register_admin_routes(app):
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
|
||||
# 관리자 권한 데코레이터
|
||||
def admin_required(view_func: Callable):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("auth.login"))
|
||||
if not getattr(current_user, "is_admin", False):
|
||||
flash("관리자 권한이 필요합니다.", "danger")
|
||||
return redirect(url_for("main.index"))
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
# 관리자 대시보드
|
||||
@admin_bp.route("/admin", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_panel():
|
||||
users = db.session.query(User).order_by(User.id.asc()).all()
|
||||
return render_template("admin.html", users=users)
|
||||
|
||||
|
||||
# 사용자 승인
|
||||
@admin_bp.route("/admin/approve/<int:user_id>", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def approve_user(user_id: int):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
abort(404)
|
||||
user.is_active = True
|
||||
db.session.commit()
|
||||
flash("사용자가 승인되었습니다.", "success")
|
||||
logging.info("✅ 승인된 사용자: %s (id=%s)", user.username, user.id)
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
|
||||
|
||||
# 사용자 관리자 승격
|
||||
@admin_bp.route("/admin/users/<int:user_id>/promote", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def promote_user(user_id: int):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
if user.id == current_user.id:
|
||||
flash("본인 계정은 승격 대상이 아닙니다.", "warning")
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
|
||||
if user.is_admin:
|
||||
flash("이미 관리자 권한을 가진 사용자입니다.", "info")
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
|
||||
user.is_admin = True
|
||||
db.session.commit()
|
||||
flash(f"{user.username} 사용자를 관리자로 승격했습니다.", "success")
|
||||
logging.info(
|
||||
"ADMIN: promoted user_id=%s to admin by admin_id=%s",
|
||||
user.id,
|
||||
current_user.id,
|
||||
)
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
|
||||
|
||||
# 사용자 삭제
|
||||
@admin_bp.route("/admin/delete/<int:user_id>", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id: int):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
abort(404)
|
||||
username = user.username
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash("사용자가 삭제되었습니다.", "success")
|
||||
logging.info("🗑 삭제된 사용자: %s (id=%s)", username, user_id)
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
|
||||
|
||||
# ▼▼▼ 사용자 비밀번호 변경(관리자용) ▼▼▼
|
||||
@admin_bp.route("/admin/users/<int:user_id>/reset_password", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def reset_password(user_id: int):
|
||||
"""
|
||||
admin.html에서 각 사용자 행 아래 폼으로부터 POST:
|
||||
- name="new_password"
|
||||
- name="confirm_password"
|
||||
CSRF는 템플릿에서 {{ csrf_token() }} 또는 {{ form.hidden_tag() }}로 포함되어야 합니다.
|
||||
"""
|
||||
new_pw = (request.form.get("new_password") or "").strip()
|
||||
confirm = (request.form.get("confirm_password") or "").strip()
|
||||
|
||||
# 서버측 검증
|
||||
if not new_pw or not confirm:
|
||||
flash("비밀번호와 확인 값을 모두 입력하세요.", "warning")
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
if new_pw != confirm:
|
||||
flash("비밀번호 확인이 일치하지 않습니다.", "warning")
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
if len(new_pw) < 8:
|
||||
flash("비밀번호는 최소 8자 이상이어야 합니다.", "warning")
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
abort(404)
|
||||
|
||||
try:
|
||||
# passlib(Argon2id) 기반 set_password 사용 (models.user에 구현됨)
|
||||
user.set_password(new_pw)
|
||||
db.session.commit()
|
||||
flash(f"사용자(ID={user.id}) 비밀번호를 변경했습니다.", "success")
|
||||
current_app.logger.info(
|
||||
"ADMIN: reset password for user_id=%s by admin_id=%s",
|
||||
user.id, current_user.id
|
||||
)
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.exception("ADMIN: reset password failed: %s", e)
|
||||
flash("비밀번호 변경 중 오류가 발생했습니다.", "danger")
|
||||
|
||||
return redirect(url_for("admin.admin_panel"))
|
||||
|
||||
|
||||
# ▼▼▼ 시스템 설정 (텔레그램 봇 관리) ▼▼▼
|
||||
@admin_bp.route("/admin/settings", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def settings():
|
||||
# 테이블 생성 확인 (임시)
|
||||
try:
|
||||
bots = TelegramBot.query.all()
|
||||
except Exception:
|
||||
db.create_all()
|
||||
bots = []
|
||||
|
||||
try:
|
||||
tsr_share_settings = {
|
||||
"share_url": get_system_setting("tsr_share_url", "//10.10.3.15/share/"),
|
||||
"share_user": get_system_setting("tsr_share_user", ""),
|
||||
"share_password": get_system_setting("tsr_share_password", ""),
|
||||
}
|
||||
except Exception:
|
||||
db.create_all()
|
||||
tsr_share_settings = {
|
||||
"share_url": "//10.10.3.15/share/",
|
||||
"share_user": "",
|
||||
"share_password": "",
|
||||
}
|
||||
|
||||
return render_template("admin_settings.html", bots=bots, tsr_share_settings=tsr_share_settings)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/settings/tsr-share", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def save_tsr_share_settings():
|
||||
share_url = (request.form.get("share_url") or "").strip()
|
||||
share_user = (request.form.get("share_user") or "").strip()
|
||||
share_password = request.form.get("share_password") or ""
|
||||
|
||||
if not share_url:
|
||||
flash("TSR 저장 Share URL을 입력하세요.", "warning")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
try:
|
||||
set_system_setting("tsr_share_url", share_url)
|
||||
set_system_setting("tsr_share_user", share_user)
|
||||
set_system_setting("tsr_share_password", share_password)
|
||||
db.session.commit()
|
||||
flash("TSR 저장소 설정이 저장되었습니다.", "success")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.exception("TSR share settings save failed: %s", e)
|
||||
flash("TSR 저장소 설정 저장 중 오류가 발생했습니다.", "danger")
|
||||
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
|
||||
@admin_bp.route("/admin/settings/bot/add", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def add_bot():
|
||||
name = request.form.get("name")
|
||||
token = request.form.get("token")
|
||||
chat_id = request.form.get("chat_id")
|
||||
desc = request.form.get("description")
|
||||
|
||||
# 알림 유형 (체크박스 다중 선택)
|
||||
notify_types = request.form.getlist("notify_types")
|
||||
notify_str = ",".join(notify_types) if notify_types else ""
|
||||
|
||||
if not (name and token and chat_id):
|
||||
flash("필수 항목(이름, 토큰, Chat ID)을 입력하세요.", "warning")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
bot = TelegramBot(
|
||||
name=name,
|
||||
token=token,
|
||||
chat_id=chat_id,
|
||||
description=desc,
|
||||
is_active=True,
|
||||
notification_types=notify_str
|
||||
)
|
||||
db.session.add(bot)
|
||||
db.session.commit()
|
||||
flash("텔레그램 봇이 추가되었습니다.", "success")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
|
||||
@admin_bp.route("/admin/settings/bot/edit/<int:bot_id>", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit_bot(bot_id):
|
||||
bot = db.session.get(TelegramBot, bot_id)
|
||||
if not bot:
|
||||
abort(404)
|
||||
|
||||
bot.name = request.form.get("name")
|
||||
bot.token = request.form.get("token")
|
||||
bot.chat_id = request.form.get("chat_id")
|
||||
bot.description = request.form.get("description")
|
||||
bot.is_active = request.form.get("is_active") == "on"
|
||||
|
||||
# 알림 유형 업데이트
|
||||
notify_types = request.form.getlist("notify_types")
|
||||
bot.notification_types = ",".join(notify_types) if notify_types else ""
|
||||
|
||||
db.session.commit()
|
||||
flash("봇 설정이 수정되었습니다.", "success")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
|
||||
@admin_bp.route("/admin/settings/bot/delete/<int:bot_id>", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_bot(bot_id):
|
||||
bot = db.session.get(TelegramBot, bot_id)
|
||||
if bot:
|
||||
db.session.delete(bot)
|
||||
db.session.commit()
|
||||
flash("봇이 삭제되었습니다.", "success")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
|
||||
@admin_bp.route("/admin/settings/bot/test/<int:bot_id>", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def test_bot(bot_id):
|
||||
if not Bot:
|
||||
flash("python-telegram-bot 라이브러리가 설치되지 않았습니다.", "danger")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
bot_obj = db.session.get(TelegramBot, bot_id)
|
||||
if not bot_obj:
|
||||
abort(404)
|
||||
|
||||
import asyncio
|
||||
|
||||
async def _send():
|
||||
bot = Bot(token=bot_obj.token)
|
||||
await bot.send_message(chat_id=bot_obj.chat_id, text="🔔 <b>테스트 메시지</b>\n이 메시지가 보이면 설정이 정상입니다.", parse_mode="HTML")
|
||||
|
||||
try:
|
||||
asyncio.run(_send())
|
||||
flash(f"'{bot_obj.name}' 봇으로 테스트 메시지를 보냈습니다.", "success")
|
||||
except Exception as e:
|
||||
flash(f"테스트 실패: {e}", "danger")
|
||||
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
|
||||
# ▼▼▼ 시스템 로그 뷰어 ▼▼▼
|
||||
@admin_bp.route("/admin/logs", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def view_logs():
|
||||
import os
|
||||
import re
|
||||
from collections import deque
|
||||
|
||||
log_folder = current_app.config.get('LOG_FOLDER')
|
||||
log_file = os.path.join(log_folder, 'app.log') if log_folder else None
|
||||
|
||||
# 1. 실제 ANSI 이스케이프 코드 (\x1B로 시작)
|
||||
ansi_escape = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]')
|
||||
|
||||
# 2. 텍스트로 찍힌 ANSI 코드 패턴 (예: [36m, [0m 등) - Werkzeug가 이스케이프 된 상태로 로그에 남길 경우 대비
|
||||
literal_ansi = re.compile(r'\[[0-9;]+m')
|
||||
|
||||
# 3. 제어 문자 제거
|
||||
control_char_re = re.compile(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]')
|
||||
|
||||
logs = []
|
||||
if log_file and os.path.exists(log_file):
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as f:
|
||||
raw_lines = deque(f, 1000)
|
||||
|
||||
for line in raw_lines:
|
||||
# A. 실제 ANSI 코드 제거
|
||||
clean_line = ansi_escape.sub('', line)
|
||||
|
||||
# B. 리터럴 ANSI 패턴 제거 (사용자가 [36m 등을 텍스트로 보고 있다면 이것이 원인)
|
||||
clean_line = literal_ansi.sub('', clean_line)
|
||||
|
||||
# C. 제어 문자 제거
|
||||
clean_line = control_char_re.sub('', clean_line)
|
||||
|
||||
# D. 앞뒤 공백 제거
|
||||
clean_line = clean_line.strip()
|
||||
|
||||
# E. 빈 줄 제외
|
||||
if clean_line:
|
||||
logs.append(clean_line)
|
||||
|
||||
except Exception as e:
|
||||
logs = [f"Error reading log file: {str(e)}"]
|
||||
else:
|
||||
logs = [f"Log file not found at: {log_file}"]
|
||||
|
||||
return render_template("admin_logs.html", logs=logs)
|
||||
@@ -0,0 +1,380 @@
|
||||
# backend/routes/auth.py
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse, urljoin
|
||||
from datetime import datetime
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
render_template,
|
||||
redirect,
|
||||
url_for,
|
||||
flash,
|
||||
request,
|
||||
session,
|
||||
current_app,
|
||||
)
|
||||
from flask_login import login_user, logout_user, current_user, login_required
|
||||
|
||||
from backend.forms.auth_forms import RegistrationForm, LoginForm
|
||||
from backend.models.user import User, db
|
||||
|
||||
# ── (선택) Telegram: 미설정이면 조용히 패스
|
||||
try:
|
||||
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.constants import ParseMode
|
||||
except Exception: # 라이브러리 미설치/미사용 환경
|
||||
Bot = None
|
||||
ParseMode = None
|
||||
InlineKeyboardButton = None
|
||||
InlineKeyboardMarkup = None
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 유틸
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
def _is_safe_url(target: str) -> bool:
|
||||
"""로그인 후 next 파라미터의 안전성 확인(동일 호스트만 허용)."""
|
||||
ref = urlparse(request.host_url)
|
||||
test = urlparse(urljoin(request.host_url, target))
|
||||
return (test.scheme in ("http", "https")) and (ref.netloc == test.netloc)
|
||||
|
||||
|
||||
def _notify(text: str, category: str = "system") -> None:
|
||||
"""
|
||||
텔레그램 알림 전송
|
||||
- DB(TelegramBot)에 등록된 활성 봇들에게 전송
|
||||
- category: 'auth', 'activity', 'system' 등
|
||||
"""
|
||||
try:
|
||||
from backend.models.telegram_bot import TelegramBot
|
||||
|
||||
# 앱 컨텍스트 안에서 실행되므로 바로 DB 접근 가능
|
||||
try:
|
||||
bots = TelegramBot.query.filter_by(is_active=True).all()
|
||||
except Exception:
|
||||
db.create_all()
|
||||
bots = []
|
||||
|
||||
# 1. DB에 봇이 없고, Config에 설정이 있는 경우 -> 자동 마이그레이션
|
||||
if not bots:
|
||||
cfg_token = (current_app.config.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||
cfg_chat = (current_app.config.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||
|
||||
if cfg_token and cfg_chat:
|
||||
new_bot = TelegramBot(
|
||||
name="기본 봇 (Config)",
|
||||
token=cfg_token,
|
||||
chat_id=cfg_chat,
|
||||
is_active=True,
|
||||
description="config.py 설정에서 자동 가져옴",
|
||||
notification_types="auth,activity,system"
|
||||
)
|
||||
db.session.add(new_bot)
|
||||
db.session.commit()
|
||||
bots = [new_bot]
|
||||
current_app.logger.info("Telegram: Config settings migrated to DB.")
|
||||
|
||||
if not bots:
|
||||
return
|
||||
|
||||
# 카테고리 필터링
|
||||
target_bots = []
|
||||
for b in bots:
|
||||
allowed = (b.notification_types or "auth,activity,system").split(",")
|
||||
if category in allowed:
|
||||
target_bots.append(b)
|
||||
|
||||
if not target_bots:
|
||||
return
|
||||
|
||||
if not (Bot and ParseMode):
|
||||
current_app.logger.warning("Telegram: Library not installed.")
|
||||
return
|
||||
|
||||
app = current_app._get_current_object()
|
||||
|
||||
async def _send_to_bot(bot_obj, msg):
|
||||
try:
|
||||
t_bot = Bot(token=bot_obj.token)
|
||||
await t_bot.send_message(chat_id=bot_obj.chat_id, text=msg, parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
app.logger.error(f"Telegram fail ({bot_obj.name}): {e}")
|
||||
|
||||
async def _send_all():
|
||||
tasks = [_send_to_bot(b, text) for b in target_bots]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
def _run_thread():
|
||||
try:
|
||||
asyncio.run(_send_all())
|
||||
except Exception as e:
|
||||
app.logger.error(f"Telegram async loop error: {e}")
|
||||
|
||||
threading.Thread(target=_run_thread, daemon=True).start()
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Telegram notification error: {e}")
|
||||
|
||||
|
||||
def _notify_with_buttons(text: str, buttons: list, category: str = "auth") -> None:
|
||||
"""
|
||||
텔레그램 알림 전송 (인라인 버튼 포함)
|
||||
- buttons: [(text, callback_data), ...] 형식의 리스트
|
||||
"""
|
||||
try:
|
||||
from backend.models.telegram_bot import TelegramBot
|
||||
|
||||
try:
|
||||
bots = TelegramBot.query.filter_by(is_active=True).all()
|
||||
except Exception:
|
||||
db.create_all()
|
||||
bots = []
|
||||
|
||||
if not bots:
|
||||
cfg_token = (current_app.config.get("TELEGRAM_BOT_TOKEN") or "").strip()
|
||||
cfg_chat = (current_app.config.get("TELEGRAM_CHAT_ID") or "").strip()
|
||||
|
||||
if cfg_token and cfg_chat:
|
||||
new_bot = TelegramBot(
|
||||
name="기본 봇 (Config)",
|
||||
token=cfg_token,
|
||||
chat_id=cfg_chat,
|
||||
is_active=True,
|
||||
description="config.py 설정에서 자동 가져옴",
|
||||
notification_types="auth,activity,system"
|
||||
)
|
||||
db.session.add(new_bot)
|
||||
db.session.commit()
|
||||
bots = [new_bot]
|
||||
|
||||
if not bots:
|
||||
return
|
||||
|
||||
target_bots = []
|
||||
for b in bots:
|
||||
allowed = (b.notification_types or "auth,activity,system").split(",")
|
||||
if category in allowed:
|
||||
target_bots.append(b)
|
||||
|
||||
if not target_bots:
|
||||
return
|
||||
|
||||
if not (Bot and ParseMode and InlineKeyboardButton and InlineKeyboardMarkup):
|
||||
current_app.logger.warning("Telegram: Library not installed.")
|
||||
return
|
||||
|
||||
# 인라인 키보드 생성
|
||||
keyboard = [[InlineKeyboardButton(btn[0], callback_data=btn[1]) for btn in buttons]]
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
|
||||
app = current_app._get_current_object()
|
||||
|
||||
async def _send_to_bot(bot_obj, msg, markup):
|
||||
try:
|
||||
t_bot = Bot(token=bot_obj.token)
|
||||
await t_bot.send_message(
|
||||
chat_id=bot_obj.chat_id,
|
||||
text=msg,
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=markup
|
||||
)
|
||||
except Exception as e:
|
||||
app.logger.error(f"Telegram fail ({bot_obj.name}): {e}")
|
||||
|
||||
async def _send_all():
|
||||
tasks = [_send_to_bot(b, text, reply_markup) for b in target_bots]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
def _run_thread():
|
||||
try:
|
||||
asyncio.run(_send_all())
|
||||
except Exception as e:
|
||||
app.logger.error(f"Telegram async loop error: {e}")
|
||||
|
||||
threading.Thread(target=_run_thread, daemon=True).start()
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Telegram notification with buttons error: {e}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Blueprint 등록 훅
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
def register_auth_routes(app):
|
||||
"""app.py에서 register_routes(app, socketio) 호출 시 사용."""
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
@app.before_request
|
||||
def _global_hooks():
|
||||
# 1. 세션 갱신 (요청마다 세션 타임아웃 연장)
|
||||
session.modified = True
|
||||
|
||||
# 2. 활동 알림 (로그인된 사용자)
|
||||
if current_user.is_authenticated:
|
||||
# 정적 리소스 및 불필요한 경로 제외
|
||||
if request.endpoint == 'static':
|
||||
return
|
||||
|
||||
# 제외할 확장자/경로 (필요 시 추가)
|
||||
ignored_exts = ('.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2')
|
||||
if request.path.lower().endswith(ignored_exts):
|
||||
return
|
||||
|
||||
# 활동 내용 구성
|
||||
msg = (
|
||||
f"👣 <b>활동 감지</b>\n"
|
||||
f"👤 <code>{current_user.username}</code>\n"
|
||||
f"📍 <code>{request.method} {request.path}</code>"
|
||||
)
|
||||
_notify(msg, category="activity")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 회원가입
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@auth_bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
current_app.logger.info("REGISTER: already auth → /index")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
form = RegistrationForm()
|
||||
if form.validate_on_submit():
|
||||
# 모델 내부에서 email/username 정규화됨(find_by_*)
|
||||
if User.find_by_email(form.email.data):
|
||||
flash("이미 등록된 이메일입니다.", "warning")
|
||||
current_app.logger.info("REGISTER: dup email %s", form.email.data)
|
||||
return render_template("register.html", form=form)
|
||||
|
||||
if User.find_by_username(form.username.data):
|
||||
flash("이미 사용 중인 사용자명입니다.", "warning")
|
||||
current_app.logger.info("REGISTER: dup username %s", form.username.data)
|
||||
return render_template("register.html", form=form)
|
||||
|
||||
# 승인 토큰 생성
|
||||
approval_token = secrets.token_urlsafe(32)
|
||||
|
||||
user = User(
|
||||
username=form.username.data,
|
||||
email=form.email.data,
|
||||
is_active=False,
|
||||
is_approved=False,
|
||||
approval_token=approval_token
|
||||
)
|
||||
user.set_password(form.password.data)
|
||||
|
||||
try:
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error("REGISTER: DB commit failed: %s", e)
|
||||
flash("회원가입 처리 중 오류가 발생했습니다. (DB Error)", "danger")
|
||||
return render_template("register.html", form=form)
|
||||
|
||||
# 텔레그램 알림 (인라인 버튼 포함)
|
||||
message = (
|
||||
f"🆕 <b>신규 가입 요청</b>\n\n"
|
||||
f"� 사용자: <code>{user.username}</code>\n"
|
||||
f"📧 이메일: <code>{user.email}</code>\n"
|
||||
f"🕐 신청시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
)
|
||||
|
||||
buttons = [
|
||||
("✅ 승인", f"approve_{approval_token}"),
|
||||
("❌ 거부", f"reject_{approval_token}")
|
||||
]
|
||||
|
||||
_notify_with_buttons(message, buttons, category="auth")
|
||||
|
||||
current_app.logger.info("REGISTER: created id=%s email=%s token=%s", user.id, user.email, approval_token[:10])
|
||||
flash("회원가입이 완료되었습니다. 관리자의 승인을 기다려주세요.", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
else:
|
||||
if request.method == "POST":
|
||||
# 폼 검증 실패 에러를 Flash 메시지로 출력
|
||||
for field_name, errors in form.errors.items():
|
||||
for error in errors:
|
||||
# 필드 객체 가져오기 (라벨 텍스트 확인용)
|
||||
field = getattr(form, field_name, None)
|
||||
label = field.label.text if field else field_name
|
||||
flash(f"{label}: {error}", "warning")
|
||||
current_app.logger.info("REGISTER: form errors=%s", form.errors)
|
||||
|
||||
return render_template("register.html", form=form)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 로그인
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
current_app.logger.info("LOGIN: already auth → /index")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
current_app.logger.info("LOGIN: form ok email=%s", form.email.data)
|
||||
user: Optional[User] = User.find_by_email(form.email.data)
|
||||
|
||||
if not user:
|
||||
flash("이메일 또는 비밀번호가 올바르지 않습니다.", "danger")
|
||||
current_app.logger.info("LOGIN: user not found")
|
||||
return render_template("login.html", form=form)
|
||||
|
||||
pass_ok = user.check_password(form.password.data)
|
||||
current_app.logger.info(
|
||||
"LOGIN: found id=%s active=%s approved=%s pass_ok=%s",
|
||||
user.id, user.is_active, user.is_approved, pass_ok
|
||||
)
|
||||
|
||||
if not pass_ok:
|
||||
flash("이메일 또는 비밀번호가 올바르지 않습니다.", "danger")
|
||||
return render_template("login.html", form=form)
|
||||
|
||||
if not user.is_approved:
|
||||
flash("계정이 아직 승인되지 않았습니다. 관리자의 승인을 기다려주세요.", "warning")
|
||||
return render_template("login.html", form=form)
|
||||
|
||||
if not user.is_active:
|
||||
flash("계정이 비활성화되었습니다. 관리자에게 문의하세요.", "warning")
|
||||
return render_template("login.html", form=form)
|
||||
|
||||
# 성공
|
||||
login_user(user, remember=form.remember.data)
|
||||
session.permanent = True
|
||||
_notify(f"🔐 <b>로그인 성공</b>\n👤 <code>{user.username}</code>", category="auth")
|
||||
current_app.logger.info("LOGIN: SUCCESS → redirect")
|
||||
|
||||
nxt = request.args.get("next")
|
||||
if nxt and _is_safe_url(nxt):
|
||||
return redirect(nxt)
|
||||
return redirect(url_for("main.index"))
|
||||
else:
|
||||
if request.method == "POST":
|
||||
current_app.logger.info("LOGIN: form errors=%s", form.errors)
|
||||
|
||||
return render_template("login.html", form=form)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 로그아웃
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@auth_bp.route("/logout", methods=["GET"])
|
||||
@login_required
|
||||
def logout():
|
||||
if current_user.is_authenticated:
|
||||
current_app.logger.info("LOGOUT: user=%s", current_user.username)
|
||||
_notify(f"🚪 <b>로그아웃</b>\n👤 <code>{current_user.username}</code>", category="auth")
|
||||
logout_user()
|
||||
flash("정상적으로 로그아웃 되었습니다.", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# backend/routes/catalog_sync.py
|
||||
from flask import Blueprint, jsonify, request
|
||||
from backend.services.dell_catalog_sync import sync_dell_catalog
|
||||
|
||||
catalog_bp = Blueprint("catalog", __name__, url_prefix="/catalog")
|
||||
|
||||
@catalog_bp.route("/sync", methods=["POST"])
|
||||
def sync_catalog():
|
||||
"""Dell Catalog 버전 정보를 동기화 (CSRF 보호 유지)"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
model = data.get("model", "PowerEdge R750")
|
||||
|
||||
sync_dell_catalog(model)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"{model} 동기화 완료"
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": f"오류: {str(e)}"
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
DRM 카탈로그 동기화 라우트
|
||||
backend/routes/drm_sync.py
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from backend.services.drm_catalog_sync import sync_from_drm, check_drm_repository
|
||||
|
||||
drm_sync_bp = Blueprint('drm_sync', __name__, url_prefix='/drm')
|
||||
|
||||
|
||||
@drm_sync_bp.route('/check', methods=['POST'])
|
||||
def check_repository():
|
||||
"""DRM 리포지토리 상태 확인"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
repository_path = data.get('repository_path')
|
||||
|
||||
if not repository_path:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'repository_path가 필요합니다'
|
||||
}), 400
|
||||
|
||||
info = check_drm_repository(repository_path)
|
||||
|
||||
return jsonify({
|
||||
'success': info.get('exists', False),
|
||||
'info': info
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@drm_sync_bp.route('/sync', methods=['POST'])
|
||||
def sync_repository():
|
||||
"""DRM 리포지토리에서 펌웨어 동기화"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
repository_path = data.get('repository_path')
|
||||
model = data.get('model', 'PowerEdge R750')
|
||||
|
||||
if not repository_path:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'repository_path가 필요합니다'
|
||||
}), 400
|
||||
|
||||
result = sync_from_drm(repository_path, model)
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'오류: {str(e)}'
|
||||
}), 500
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
from flask_login import login_required
|
||||
|
||||
from config import Config
|
||||
import chardet
|
||||
|
||||
file_view_bp = Blueprint("file_view", __name__)
|
||||
|
||||
|
||||
def register_file_view(app):
|
||||
"""블루프린트 등록"""
|
||||
app.register_blueprint(file_view_bp)
|
||||
|
||||
|
||||
def _safe_within(base: Path, target: Path) -> bool:
|
||||
"""
|
||||
target 이 base 디렉터리 내부인지 검사 (경로 탈출 방지)
|
||||
"""
|
||||
try:
|
||||
target.resolve().relative_to(base.resolve())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _decode_bytes(raw: bytes) -> str:
|
||||
"""
|
||||
파일 바이트 → 문자열 디코딩 (감지 → utf-8 → cp949 순서로 시도)
|
||||
"""
|
||||
enc = (chardet.detect(raw).get("encoding") or "utf-8").strip().lower()
|
||||
for cand in (enc, "utf-8", "cp949"):
|
||||
try:
|
||||
return raw.decode(cand)
|
||||
except Exception:
|
||||
continue
|
||||
# 최후의 수단: 손실 허용 디코딩
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
@file_view_bp.route("/view_file", methods=["GET"])
|
||||
@login_required
|
||||
def view_file():
|
||||
"""
|
||||
파일 내용을 읽어 반환.
|
||||
- /view_file?folder=idrac_info&filename=abc.txt
|
||||
- /view_file?folder=backup&date=<백업폴더명>&filename=abc.txt
|
||||
- ?raw=1 을 붙이면 text/plain 으로 원문을 반환 (모달 표시용)
|
||||
"""
|
||||
folder = request.args.get("folder", "").strip()
|
||||
date = request.args.get("date", "").strip()
|
||||
filename = request.args.get("filename", "").strip()
|
||||
want_raw = request.args.get("raw")
|
||||
|
||||
if not filename:
|
||||
return jsonify({"error": "파일 이름이 없습니다."}), 400
|
||||
|
||||
# 파일명/폴더명은 유니코드 보존. 상위 경로만 제거하여 보안 유지
|
||||
safe_name = Path(filename).name
|
||||
|
||||
if folder == "backup":
|
||||
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()
|
||||
|
||||
logging.info(
|
||||
"file_view: folder=%s date=%s filename=%s | base=%s | target=%s",
|
||||
folder, date, filename, str(base), str(target)
|
||||
)
|
||||
|
||||
if not _safe_within(base, target) or not target.is_file():
|
||||
logging.warning("file_view: 파일 없음: %s", str(target))
|
||||
return jsonify({"error": "파일을 찾을 수 없습니다."}), 404
|
||||
|
||||
try:
|
||||
raw = target.read_bytes()
|
||||
content = _decode_bytes(raw)
|
||||
|
||||
if want_raw:
|
||||
# 텍스트 원문 반환 (모달에서 fetch().text()로 사용)
|
||||
return Response(content, mimetype="text/plain; charset=utf-8")
|
||||
|
||||
# JSON으로 감싸서 반환 (기존 사용처 호환)
|
||||
return jsonify({"content": content})
|
||||
|
||||
except Exception as e:
|
||||
logging.error("file_view: 파일 읽기 실패: %s → %s", str(target), e)
|
||||
return jsonify({"error": "파일 열기 중 오류 발생"}), 500
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
home_bp = Blueprint("home", __name__, url_prefix="/home")
|
||||
|
||||
|
||||
def register_home_routes(app):
|
||||
app.register_blueprint(home_bp)
|
||||
|
||||
|
||||
@home_bp.route("/", methods=["GET"])
|
||||
def home():
|
||||
return render_template("home.html")
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Dell iDRAC 멀티 서버 관리 라우트
|
||||
backend/routes/idrac_routes.py
|
||||
- CSRF 보호 제외 추가
|
||||
- 펌웨어 관리 기능 제거됨 (Server Config Analysis로 대체)
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from backend.services.idrac_redfish_client import DellRedfishClient
|
||||
from backend.models.idrac_server import IdracServer, db
|
||||
from datetime import datetime
|
||||
import threading
|
||||
|
||||
# Blueprint 생성
|
||||
idrac_bp = Blueprint('idrac', __name__, url_prefix='/idrac')
|
||||
|
||||
# ========================================
|
||||
# 페이지 라우트
|
||||
# ========================================
|
||||
|
||||
@idrac_bp.route('/')
|
||||
def index():
|
||||
"""iDRAC 서버 관리 메인 페이지"""
|
||||
from flask import render_template
|
||||
return render_template('idrac_firmware.html')
|
||||
|
||||
# ========================================
|
||||
# 서버 관리 API (CRUD)
|
||||
# ========================================
|
||||
|
||||
@idrac_bp.route('/api/servers', methods=['GET'])
|
||||
def get_servers():
|
||||
"""서버 목록 조회"""
|
||||
try:
|
||||
group_filter = request.args.get('group')
|
||||
query = IdracServer.query.filter_by(is_active=True)
|
||||
|
||||
if group_filter and group_filter != 'all':
|
||||
query = query.filter_by(group_name=group_filter)
|
||||
|
||||
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': str(e)})
|
||||
|
||||
@idrac_bp.route('/api/servers', methods=['POST'])
|
||||
def add_server():
|
||||
"""서버 추가"""
|
||||
try:
|
||||
data = request.json
|
||||
# 필수 필드 체크
|
||||
if not all(k in data for k in ['name', 'ip_address', 'password']):
|
||||
return jsonify({'success': False, 'message': '필수 필드 누락'})
|
||||
|
||||
# 활성 서버 중복 체크
|
||||
if IdracServer.query.filter_by(ip_address=data['ip_address'], is_active=True).first():
|
||||
return jsonify({'success': False, 'message': '이미 등록된 IP입니다.'})
|
||||
|
||||
# Soft delete된 서버가 있는지 확인하고 재활성화
|
||||
existing_server = IdracServer.query.filter_by(ip_address=data['ip_address'], is_active=False).first()
|
||||
if existing_server:
|
||||
# 기존 서버 재활성화 및 정보 업데이트
|
||||
existing_server.is_active = True
|
||||
existing_server.name = data['name']
|
||||
existing_server.username = data.get('username', 'root')
|
||||
existing_server.password = data['password']
|
||||
existing_server.group_name = data.get('group_name')
|
||||
existing_server.model = data.get('model')
|
||||
existing_server.notes = data.get('notes')
|
||||
existing_server.status = 'registered'
|
||||
existing_server.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'message': '서버 재등록 완료', 'server': existing_server.to_dict()})
|
||||
|
||||
# 새 서버 생성
|
||||
new_server = IdracServer(
|
||||
name=data['name'],
|
||||
ip_address=data['ip_address'],
|
||||
username=data.get('username', 'root'),
|
||||
password=data['password'],
|
||||
group_name=data.get('group_name'),
|
||||
model=data.get('model'),
|
||||
notes=data.get('notes')
|
||||
)
|
||||
db.session.add(new_server)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'message': '서버 추가 완료', 'server': new_server.to_dict()})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@idrac_bp.route('/api/servers/<int:server_id>', methods=['DELETE'])
|
||||
def delete_server(server_id):
|
||||
"""서버 삭제 (Soft Delete)"""
|
||||
try:
|
||||
server = IdracServer.query.get(server_id)
|
||||
if not server:
|
||||
return jsonify({'success': False, 'message': '서버를 찾을 수 없습니다'})
|
||||
|
||||
# Soft delete: is_active를 False로 설정
|
||||
# IP 주소는 유지하여 재등록 시 재활성화 가능
|
||||
server.is_active = False
|
||||
server.status = 'deleted'
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'message': '서버 삭제 완료'})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
@idrac_bp.route('/api/groups', methods=['GET'])
|
||||
def get_groups():
|
||||
"""등록된 그룹 목록"""
|
||||
try:
|
||||
groups = db.session.query(IdracServer.group_name).filter(
|
||||
IdracServer.group_name != None,
|
||||
IdracServer.is_active == True
|
||||
).distinct().all()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'groups': [g[0] for g in groups if g[0]]
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': 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': '연결 성공'})
|
||||
else:
|
||||
server.status = 'offline'
|
||||
db.session.commit()
|
||||
return jsonify({'success': False, 'message': '연결 실패'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': 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', [])
|
||||
results = []
|
||||
|
||||
for sid in server_ids:
|
||||
server = IdracServer.query.get(sid)
|
||||
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': sid, 'success': True})
|
||||
else:
|
||||
server.status = 'offline'
|
||||
results.append({'server_id': sid, 'success': False, 'message': 'Failed'})
|
||||
except Exception as e:
|
||||
server.status = 'offline'
|
||||
results.append({'server_id': sid, 'success': False, 'message': str(e)})
|
||||
|
||||
db.session.commit()
|
||||
success = sum(1 for r in results if r['success'])
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'summary': {'success': success, 'failed': len(results)-success},
|
||||
'results': results
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': 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')
|
||||
results = []
|
||||
|
||||
for sid in server_ids:
|
||||
server = IdracServer.query.get(sid)
|
||||
if not server: continue
|
||||
|
||||
try:
|
||||
client = DellRedfishClient(server.ip_address, server.username, server.password)
|
||||
if client.reboot_server(reboot_type):
|
||||
results.append({'server_id': sid, 'success': True, 'message': 'Reboot initiated'})
|
||||
else:
|
||||
results.append({'server_id': sid, 'success': False, 'message': 'Reboot failed'})
|
||||
except Exception as e:
|
||||
results.append({'server_id': sid, 'success': False, 'message': str(e)})
|
||||
|
||||
success = sum(1 for r in results if r['success'])
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'summary': {'success': success, 'failed': len(results)-success},
|
||||
'results': results
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)})
|
||||
|
||||
|
||||
# ========================================
|
||||
# Blueprint 등록 함수
|
||||
# ========================================
|
||||
|
||||
|
||||
def register_idrac_routes(app):
|
||||
"""
|
||||
idrac_routes 등록 및 초기화
|
||||
"""
|
||||
# Blueprint 등록
|
||||
app.register_blueprint(idrac_bp)
|
||||
|
||||
# CSRF 제외 - app에 이미 설정된 csrf 인스턴스 사용
|
||||
try:
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
# app에 이미 csrf가 설정되어 있으므로, 해당 인스턴스를 가져와서 exempt 설정
|
||||
csrf = app.extensions.get('csrf')
|
||||
if csrf:
|
||||
csrf.exempt(idrac_bp)
|
||||
except Exception as e:
|
||||
app.logger.warning(f"CSRF exemption failed for idrac_bp: {e}")
|
||||
|
||||
# DB 생성
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Flask Blueprint for iDRAC Job Monitoring (Redfish 버전)
|
||||
기존 routes/jobs.py 또는 backend/routes/jobs.py를 이 파일로 교체하세요.
|
||||
"""
|
||||
import time
|
||||
import logging
|
||||
from flask import Blueprint, render_template, jsonify, request
|
||||
from flask_login import login_required
|
||||
|
||||
from backend.services.idrac_jobs import (
|
||||
scan_all,
|
||||
parse_ip_list,
|
||||
load_ip_list,
|
||||
LRUJobCache,
|
||||
is_active_status,
|
||||
is_done_status,
|
||||
parse_iso_datetime,
|
||||
iso_now
|
||||
)
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Blueprint 생성
|
||||
jobs_bp = Blueprint("jobs", __name__, url_prefix="/jobs")
|
||||
|
||||
# Job 캐시 (전역)
|
||||
MAX_CACHE_SIZE = int(os.getenv("MAX_CACHE_SIZE", "10000"))
|
||||
CACHE_GC_INTERVAL = int(os.getenv("CACHE_GC_INTERVAL", "3600"))
|
||||
JOB_GRACE_MINUTES = int(os.getenv("JOB_GRACE_MINUTES", "60"))
|
||||
JOB_RECENCY_HOURS = int(os.getenv("JOB_RECENCY_HOURS", "24"))
|
||||
|
||||
JOB_CACHE = LRUJobCache(max_size=MAX_CACHE_SIZE)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Routes
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
@jobs_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
def jobs_page():
|
||||
"""메인 페이지"""
|
||||
return render_template("jobs.html")
|
||||
|
||||
|
||||
@jobs_bp.route("/config", methods=["GET"])
|
||||
@login_required
|
||||
def jobs_config():
|
||||
"""프론트엔드 설정 제공"""
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"config": {
|
||||
"grace_minutes": JOB_GRACE_MINUTES,
|
||||
"recency_hours": JOB_RECENCY_HOURS,
|
||||
"poll_interval_ms": int(os.getenv("POLL_INTERVAL_MS", "10000")),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@jobs_bp.route("/iplist", methods=["GET"])
|
||||
@login_required
|
||||
def get_ip_list():
|
||||
"""IP 목록 조회 (파일에서)"""
|
||||
try:
|
||||
ips = load_ip_list()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"ips": ips,
|
||||
"count": len(ips)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Failed to load IP list")
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@jobs_bp.route("/scan", methods=["POST"])
|
||||
@login_required
|
||||
def scan_jobs():
|
||||
"""
|
||||
Job 스캔 및 모니터링
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"ips": List[str] (optional),
|
||||
"method": "redfish" (기본값),
|
||||
"recency_hours": int (기본: 24),
|
||||
"grace_minutes": int (기본: 60),
|
||||
"include_tracked_done": bool (기본: True)
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"ok": True,
|
||||
"count": int,
|
||||
"items": [
|
||||
{
|
||||
"ip": str,
|
||||
"ok": bool,
|
||||
"error": str (if not ok),
|
||||
"jobs": List[Dict]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# IP 목록
|
||||
ip_input = data.get("ips")
|
||||
if ip_input:
|
||||
ips = parse_ip_list("\n".join(ip_input) if isinstance(ip_input, list) else str(ip_input))
|
||||
else:
|
||||
ips = load_ip_list()
|
||||
|
||||
if not ips:
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": "No IPs provided",
|
||||
"items": []
|
||||
}), 400
|
||||
|
||||
# 파라미터
|
||||
method = data.get("method", "redfish") # redfish가 기본값
|
||||
recency_hours = int(data.get("recency_hours", JOB_RECENCY_HOURS))
|
||||
grace_minutes = int(data.get("grace_minutes", JOB_GRACE_MINUTES))
|
||||
include_tracked_done = bool(data.get("include_tracked_done", True))
|
||||
|
||||
grace_sec = grace_minutes * 60
|
||||
cutoff = time.time() - recency_hours * 3600
|
||||
|
||||
# 현재 IP 목록과 다른 캐시 항목 제거
|
||||
JOB_CACHE.clear_for_ips(set(ips))
|
||||
|
||||
# 스캔 실행
|
||||
try:
|
||||
items = scan_all(ips, method=method)
|
||||
except Exception as e:
|
||||
logger.exception("Scan failed")
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
"items": []
|
||||
}), 500
|
||||
|
||||
now = time.time()
|
||||
|
||||
# 캐시 업데이트
|
||||
for item in items:
|
||||
ip = item.get("ip", "")
|
||||
if not item.get("ok") or not isinstance(item.get("jobs"), list):
|
||||
continue
|
||||
|
||||
for job in item["jobs"]:
|
||||
status = job.get("Status")
|
||||
message = job.get("Message")
|
||||
active_now = is_active_status(status, message)
|
||||
done_now = is_done_status(status)
|
||||
|
||||
# 시작 시간 파싱
|
||||
start_ts = parse_iso_datetime(job.get("StartTime"))
|
||||
|
||||
# 리센시 판정
|
||||
if not active_now:
|
||||
if start_ts is None or start_ts < cutoff:
|
||||
continue
|
||||
|
||||
# 캐시 키 생성
|
||||
key = _make_cache_key(ip, job)
|
||||
entry = JOB_CACHE.get(key)
|
||||
|
||||
if entry is None:
|
||||
JOB_CACHE.set(key, {
|
||||
"record": dict(job),
|
||||
"first_seen_active": (now if active_now else None),
|
||||
"became_done_at": (now if done_now else None),
|
||||
"first_seen": now,
|
||||
"last_seen": now,
|
||||
"start_ts": start_ts,
|
||||
})
|
||||
else:
|
||||
entry["record"] = dict(job)
|
||||
entry["last_seen"] = now
|
||||
|
||||
if active_now and not entry.get("first_seen_active"):
|
||||
entry["first_seen_active"] = now
|
||||
|
||||
if done_now and not entry.get("became_done_at"):
|
||||
entry["became_done_at"] = now
|
||||
elif not done_now:
|
||||
entry["became_done_at"] = None
|
||||
|
||||
if start_ts:
|
||||
entry["start_ts"] = start_ts
|
||||
|
||||
JOB_CACHE.set(key, entry)
|
||||
|
||||
# 응답 생성
|
||||
out_items = []
|
||||
for item in items:
|
||||
ip = item.get("ip", "")
|
||||
shown_jobs = []
|
||||
|
||||
# 현재 Active Job
|
||||
current_active = []
|
||||
if item.get("ok") and isinstance(item.get("jobs"), list):
|
||||
for job in item["jobs"]:
|
||||
if is_active_status(job.get("Status"), job.get("Message")):
|
||||
key = _make_cache_key(ip, job)
|
||||
if key in JOB_CACHE.keys():
|
||||
current_active.append(JOB_CACHE.get(key)["record"])
|
||||
|
||||
if current_active:
|
||||
shown_jobs = current_active
|
||||
else:
|
||||
# Active가 없을 때: 추적된 최근 완료 Job 표시
|
||||
if include_tracked_done:
|
||||
for key in JOB_CACHE.keys():
|
||||
if key[0] != ip:
|
||||
continue
|
||||
|
||||
entry = JOB_CACHE.get(key)
|
||||
if not entry:
|
||||
continue
|
||||
|
||||
start_ok = (entry.get("start_ts") or 0) >= cutoff
|
||||
done_at = entry.get("became_done_at")
|
||||
done_ok = bool(done_at and now - done_at <= grace_sec)
|
||||
still_active = entry.get("became_done_at") is None
|
||||
|
||||
if still_active and start_ok:
|
||||
shown_jobs.append(entry["record"])
|
||||
elif done_ok and start_ok:
|
||||
rec = dict(entry["record"])
|
||||
rec["RecentlyCompleted"] = True
|
||||
rec["CompletedAt"] = iso_now()
|
||||
shown_jobs.append(rec)
|
||||
|
||||
out_items.append({
|
||||
"ip": ip,
|
||||
"ok": item.get("ok"),
|
||||
"error": item.get("error"),
|
||||
"jobs": sorted(shown_jobs, key=lambda r: r.get("JID", ""))
|
||||
})
|
||||
|
||||
# 캐시 GC (조건부)
|
||||
if now - JOB_CACHE.last_gc >= CACHE_GC_INTERVAL:
|
||||
JOB_CACHE.gc(max_age_seconds=24 * 3600)
|
||||
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"count": len(out_items),
|
||||
"items": out_items
|
||||
})
|
||||
|
||||
|
||||
def _make_cache_key(ip: str, job: dict):
|
||||
"""캐시 키 생성"""
|
||||
jid = (job.get("JID") or "").strip()
|
||||
if jid:
|
||||
return (ip, jid)
|
||||
name = (job.get("Name") or "").strip()
|
||||
return (ip, f"NOJID::{name}")
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 기존 패턴에 맞는 register 함수 추가
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
def register_jobs_routes(app):
|
||||
"""
|
||||
iDRAC Job 모니터링 라우트 등록
|
||||
기존 프로젝트 패턴에 맞춘 함수
|
||||
"""
|
||||
from flask import Flask
|
||||
app.register_blueprint(jobs_bp)
|
||||
logger.info("Jobs routes registered at /jobs")
|
||||
@@ -0,0 +1,675 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import time
|
||||
import shutil
|
||||
import zipfile
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session, send_from_directory, send_file
|
||||
from flask_login import login_required, current_user
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from watchdog.observers import Observer
|
||||
from natsort import natsorted
|
||||
|
||||
from backend.services.ip_processor import (
|
||||
save_ip_addresses,
|
||||
process_ips_concurrently,
|
||||
get_progress,
|
||||
on_complete,
|
||||
)
|
||||
from backend.services.watchdog_handler import FileCreatedHandler
|
||||
from config import Config
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
executor = ThreadPoolExecutor(max_workers=Config.MAX_WORKERS)
|
||||
|
||||
|
||||
def register_main_routes(app, socketio):
|
||||
app.register_blueprint(main_bp)
|
||||
|
||||
@app.context_processor
|
||||
def inject_user():
|
||||
return dict(current_user=current_user)
|
||||
|
||||
@app.before_request
|
||||
def make_session_permanent():
|
||||
session.permanent = True
|
||||
if current_user.is_authenticated:
|
||||
session.modified = True
|
||||
|
||||
|
||||
def _is_safe_backup_folder_name(folder_name: str) -> bool:
|
||||
if not folder_name or folder_name == "archive":
|
||||
return False
|
||||
if ".." in folder_name:
|
||||
return False
|
||||
return "/" not in folder_name and "\\" not in folder_name
|
||||
|
||||
|
||||
def _get_repository_folder(repo_type: str) -> tuple[str, Path] | tuple[None, None]:
|
||||
repositories = {
|
||||
"mac": ("MAC", Path(Config.MAC_FOLDER)),
|
||||
"guid": ("GUID", Path(Config.GUID_FOLDER)),
|
||||
"gpu": ("GPU", Path(Config.GPU_FOLDER)),
|
||||
}
|
||||
return repositories.get(repo_type, (None, None))
|
||||
|
||||
|
||||
def _safe_upload_filename(filename: str) -> str:
|
||||
return Path(filename or "").name.strip()
|
||||
|
||||
|
||||
def _unique_upload_path(target_dir: Path, filename: str) -> Path:
|
||||
candidate = target_dir / filename
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
|
||||
stem = candidate.stem
|
||||
suffix = candidate.suffix
|
||||
index = 1
|
||||
while True:
|
||||
renamed = target_dir / f"{stem}_{index}{suffix}"
|
||||
if not renamed.exists():
|
||||
return renamed
|
||||
index += 1
|
||||
|
||||
|
||||
def _safe_repo_file_path(source_dir: Path, filename: str) -> Path | None:
|
||||
safe_name = Path(filename or "").name
|
||||
if not safe_name:
|
||||
return None
|
||||
|
||||
target = (source_dir / safe_name).resolve()
|
||||
try:
|
||||
target.relative_to(source_dir.resolve())
|
||||
except Exception:
|
||||
return None
|
||||
return target if target.is_file() else None
|
||||
|
||||
|
||||
@main_bp.route("/")
|
||||
@main_bp.route("/index", methods=["GET"])
|
||||
@login_required
|
||||
def index():
|
||||
script_dir = Path(Config.SCRIPT_FOLDER)
|
||||
xml_dir = Path(Config.XML_FOLDER)
|
||||
info_dir = Path(Config.IDRAC_INFO_FOLDER)
|
||||
backup_dir = Path(Config.BACKUP_FOLDER)
|
||||
|
||||
# 1. 스크립트 목록 조회 및 카테고리 분류
|
||||
all_scripts = [f.name for f in script_dir.glob("*") if f.is_file() and f.name != ".env"]
|
||||
all_scripts = natsorted(all_scripts)
|
||||
|
||||
grouped_scripts = {}
|
||||
for script in all_scripts:
|
||||
upper = script.upper()
|
||||
category = "General"
|
||||
if upper.startswith("GPU"):
|
||||
category = "GPU"
|
||||
elif upper.startswith("LOM"):
|
||||
category = "LOM"
|
||||
elif upper.startswith("TYPE") or upper.startswith("XE"):
|
||||
category = "Server Models"
|
||||
elif "MAC" in upper:
|
||||
category = "MAC Info"
|
||||
elif "GUID" in upper:
|
||||
category = "GUID Info"
|
||||
elif "SET_" in upper or "CONFIG" in upper:
|
||||
category = "Configuration"
|
||||
|
||||
if category not in grouped_scripts:
|
||||
grouped_scripts[category] = []
|
||||
grouped_scripts[category].append(script)
|
||||
|
||||
# 카테고리 정렬 (General은 마지막에)
|
||||
sorted_categories = sorted(grouped_scripts.keys())
|
||||
if "General" in sorted_categories:
|
||||
sorted_categories.remove("General")
|
||||
sorted_categories.append("General")
|
||||
|
||||
grouped_scripts_sorted = {k: grouped_scripts[k] for k in sorted_categories}
|
||||
|
||||
# 2. XML 파일 목록
|
||||
xml_files = [f.name for f in xml_dir.glob("*.xml")]
|
||||
|
||||
# 3. 페이지네이션 및 파일 목록
|
||||
page = int(request.args.get("page", 1))
|
||||
info_files = [f.name for f in info_dir.glob("*") if f.is_file()]
|
||||
info_files = natsorted(info_files)
|
||||
|
||||
start = (page - 1) * Config.FILES_PER_PAGE
|
||||
end = start + Config.FILES_PER_PAGE
|
||||
files_to_display = [{"name": Path(f).stem, "file": f} for f in info_files[start:end]]
|
||||
|
||||
total_pages = (len(info_files) + Config.FILES_PER_PAGE - 1) // Config.FILES_PER_PAGE
|
||||
|
||||
start_page = ((page - 1) // 10) * 10 + 1
|
||||
end_page = min(start_page + 9, total_pages)
|
||||
|
||||
# 4. 백업 폴더 목록
|
||||
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_folder_names = [d.name for d in backup_dirs]
|
||||
|
||||
backup_page = int(request.args.get("backup_page", 1))
|
||||
start_b = (backup_page - 1) * Config.BACKUP_FILES_PER_PAGE
|
||||
end_b = start_b + Config.BACKUP_FILES_PER_PAGE
|
||||
backup_slice = backup_dirs[start_b:end_b]
|
||||
total_backup_pages = (len(backup_dirs) + Config.BACKUP_FILES_PER_PAGE - 1) // Config.BACKUP_FILES_PER_PAGE
|
||||
|
||||
backup_files = {}
|
||||
for d in backup_slice:
|
||||
files = [f.name for f in d.iterdir() if f.is_file()]
|
||||
backup_files[d.name] = {"files": files, "count": len(files)}
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
files_to_display=files_to_display,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
start_page=start_page,
|
||||
end_page=end_page,
|
||||
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)
|
||||
backup_folder_names=backup_folder_names,
|
||||
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")
|
||||
job_type = request.form.get("job_type")
|
||||
|
||||
# 변수 초기화
|
||||
selected_script = ""
|
||||
profile_name = None
|
||||
xml_file_path = None
|
||||
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
|
||||
|
||||
ip_files = save_ip_addresses(ips, Config.UPLOAD_FOLDER)
|
||||
total_files = len(ip_files)
|
||||
|
||||
handler = FileCreatedHandler(job_id, total_files)
|
||||
observer = Observer()
|
||||
observer.schedule(handler, Config.IDRAC_INFO_FOLDER, recursive=False)
|
||||
observer.start()
|
||||
|
||||
future = executor.submit(
|
||||
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}, type: {job_type}, script: {selected_script}, profile: {profile_name}")
|
||||
return jsonify({"job_id": job_id})
|
||||
|
||||
|
||||
@main_bp.route("/progress_status/<job_id>")
|
||||
@login_required
|
||||
def progress_status(job_id: str):
|
||||
return jsonify({"progress": get_progress(job_id)})
|
||||
|
||||
|
||||
@main_bp.route("/backup", methods=["POST"])
|
||||
@login_required
|
||||
def backup_files():
|
||||
selected_folder = request.form.get("backup_target_folder", "").strip()
|
||||
new_folder = request.form.get("backup_prefix", "").strip()
|
||||
is_new_folder = not selected_folder or selected_folder == "__new__"
|
||||
|
||||
folder_name = new_folder if is_new_folder else selected_folder
|
||||
if not _is_safe_backup_folder_name(folder_name):
|
||||
flash("사용할 수 없는 백업 폴더명입니다.")
|
||||
return redirect(url_for("main.index"))
|
||||
if is_new_folder and not folder_name.startswith("PO"):
|
||||
flash("새 백업 폴더명은 PO로 시작해야 합니다.")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
backup_root = Path(Config.BACKUP_FOLDER).resolve()
|
||||
backup_path = (backup_root / folder_name).resolve()
|
||||
if backup_root not in backup_path.parents:
|
||||
flash("사용할 수 없는 백업 경로입니다.")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
backup_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
info_dir = Path(Config.IDRAC_INFO_FOLDER)
|
||||
moved_count = 0
|
||||
skipped_count = 0
|
||||
for file in info_dir.iterdir():
|
||||
if file.is_file():
|
||||
target_file = backup_path / file.name
|
||||
if target_file.exists():
|
||||
skipped_count += 1
|
||||
continue
|
||||
shutil.move(str(file), str(target_file))
|
||||
moved_count += 1
|
||||
|
||||
if moved_count:
|
||||
message = f"'{folder_name}' 폴더에 {moved_count}개 파일을 백업했습니다."
|
||||
if skipped_count:
|
||||
message += f" 중복 파일 {skipped_count}개는 건너뛰었습니다."
|
||||
flash(message)
|
||||
else:
|
||||
flash("백업할 처리된 파일이 없습니다.")
|
||||
logging.info(f"백업 완료: {folder_name}, moved={moved_count}, skipped={skipped_count}")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@main_bp.route("/download/<filename>")
|
||||
@login_required
|
||||
def download_file(filename: str):
|
||||
# send_from_directory는 내부적으로 안전 검사를 수행
|
||||
return send_from_directory(Config.IDRAC_INFO_FOLDER, filename, as_attachment=True)
|
||||
|
||||
|
||||
@main_bp.route("/delete/<filename>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_file(filename: str):
|
||||
file_path = Path(Config.IDRAC_INFO_FOLDER) / filename
|
||||
if file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
flash(f"'{filename}' 파일이 삭제되었습니다.", "success")
|
||||
logging.info(f"파일 삭제됨: {filename}")
|
||||
except Exception as e:
|
||||
logging.error(f"파일 삭제 오류: {e}")
|
||||
flash("파일 삭제 중 오류가 발생했습니다.", "danger")
|
||||
else:
|
||||
flash("파일이 존재하지 않습니다.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@main_bp.route("/download_zip", methods=["POST"])
|
||||
@login_required
|
||||
def download_zip():
|
||||
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 target_dir.glob("*"):
|
||||
if file.is_file():
|
||||
zipf.write(file, arcname=file.name)
|
||||
|
||||
try:
|
||||
response = send_file(str(zip_path), as_attachment=True)
|
||||
return response
|
||||
finally:
|
||||
# 응답 후 임시 ZIP 삭제
|
||||
try:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
except Exception as e:
|
||||
logging.warning(f"임시 ZIP 삭제 실패: {e}")
|
||||
|
||||
|
||||
@main_bp.route("/download_backup/<date>/<filename>")
|
||||
@login_required
|
||||
def download_backup_file(date: str, filename: str):
|
||||
backup_path = Path(Config.BACKUP_FOLDER) / date
|
||||
return send_from_directory(str(backup_path), filename, as_attachment=True)
|
||||
|
||||
|
||||
@main_bp.route("/move_backup_files", methods=["POST"])
|
||||
@login_required
|
||||
def move_backup_files():
|
||||
data = request.get_json()
|
||||
filename = data.get("filename")
|
||||
source_folder = data.get("source_folder")
|
||||
target_folder = data.get("target_folder")
|
||||
|
||||
if not all([filename, source_folder, target_folder]):
|
||||
return jsonify({"success": False, "message": "필수 파라미터가 누락되었습니다."}), 400
|
||||
|
||||
base_backup = Path(Config.BACKUP_FOLDER)
|
||||
src_path = base_backup / source_folder / filename
|
||||
dst_path = base_backup / target_folder / filename
|
||||
|
||||
# 경로 보안 검사 및 파일 존재 확인
|
||||
try:
|
||||
if not src_path.exists():
|
||||
return jsonify({"success": False, "message": "원본 파일이 존재하지 않습니다."}), 404
|
||||
|
||||
# 상위 경로 탈출 방지 확인 (간단 검증)
|
||||
if ".." in source_folder or ".." in target_folder:
|
||||
return jsonify({"success": False, "message": "잘못된 경로입니다."}), 400
|
||||
|
||||
if not (base_backup / target_folder).exists():
|
||||
return jsonify({"success": False, "message": "대상 폴더가 존재하지 않습니다."}), 404
|
||||
|
||||
shutil.move(str(src_path), str(dst_path))
|
||||
logging.info(f"파일 이동 성공: {filename} from {source_folder} to {target_folder}")
|
||||
return jsonify({"success": True, "message": "파일이 이동되었습니다."})
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
@main_bp.route("/archive_repository/<repo_type>", methods=["POST"])
|
||||
@login_required
|
||||
def archive_repository(repo_type: str):
|
||||
label, source_dir = _get_repository_folder(repo_type)
|
||||
if not label or not source_dir:
|
||||
flash("알 수 없는 저장소입니다.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
files = [f for f in source_dir.iterdir() if f.is_file()] if source_dir.exists() else []
|
||||
if not files:
|
||||
flash(f"{label} 저장소에 아카이브할 파일이 없습니다.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
archive_dir = Path(Config.ARCHIVE_FOLDER) / "repository" / repo_type / timestamp
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
moved_count = 0
|
||||
try:
|
||||
for file in files:
|
||||
shutil.move(str(file), str(archive_dir / file.name))
|
||||
moved_count += 1
|
||||
|
||||
flash(f"{label} 파일 {moved_count}개를 아카이브로 이동했습니다.", "success")
|
||||
logging.info(f"{label} 저장소 아카이빙 성공: {moved_count} files -> {archive_dir}")
|
||||
except Exception as e:
|
||||
logging.error(f"{label} 저장소 아카이빙 오류: {e}")
|
||||
flash("저장소 파일을 아카이브로 이동하는 중 오류가 발생했습니다.", "danger")
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@main_bp.route("/download_repository_files/<repo_type>", methods=["POST"])
|
||||
@login_required
|
||||
def download_repository_files(repo_type: str):
|
||||
label, source_dir = _get_repository_folder(repo_type)
|
||||
if not label or not source_dir:
|
||||
flash("알 수 없는 저장소입니다.", "warning")
|
||||
return redirect(url_for("main.index", workspace=repo_type))
|
||||
|
||||
selected_paths = []
|
||||
for filename in request.form.getlist("files"):
|
||||
file_path = _safe_repo_file_path(source_dir, filename)
|
||||
if file_path:
|
||||
selected_paths.append(file_path)
|
||||
|
||||
if not selected_paths:
|
||||
flash("다운로드할 파일을 선택하세요.", "warning")
|
||||
return redirect(url_for("main.index", workspace=repo_type))
|
||||
|
||||
if len(selected_paths) == 1:
|
||||
return send_from_directory(str(source_dir), selected_paths[0].name, as_attachment=True)
|
||||
|
||||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
zip_path = Path(Config.TEMP_ZIP_FOLDER) / f"{repo_type}_selected_{timestamp}.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf:
|
||||
for file_path in selected_paths:
|
||||
zipf.write(file_path, arcname=file_path.name)
|
||||
|
||||
try:
|
||||
return send_file(str(zip_path), as_attachment=True, download_name=zip_path.name)
|
||||
finally:
|
||||
try:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
except Exception as e:
|
||||
logging.warning("선택 파일 ZIP 삭제 실패: %s", e)
|
||||
|
||||
|
||||
@main_bp.route("/delete_repository_file/<repo_type>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_repository_file(repo_type: str):
|
||||
label, source_dir = _get_repository_folder(repo_type)
|
||||
if not label or not source_dir:
|
||||
flash("알 수 없는 저장소입니다.", "warning")
|
||||
return redirect(url_for("main.index", workspace=repo_type))
|
||||
|
||||
file_path = _safe_repo_file_path(source_dir, request.form.get("filename", ""))
|
||||
if not file_path:
|
||||
flash("삭제할 파일을 찾을 수 없습니다.", "warning")
|
||||
return redirect(url_for("main.index", workspace=repo_type))
|
||||
|
||||
try:
|
||||
file_path.unlink()
|
||||
flash(f"{label} 파일 '{file_path.name}'을 삭제했습니다.", "success")
|
||||
except Exception as e:
|
||||
logging.error("%s 저장소 파일 삭제 오류: %s", label, e)
|
||||
flash("파일 삭제 중 오류가 발생했습니다.", "danger")
|
||||
|
||||
return redirect(url_for("main.index", workspace=repo_type))
|
||||
|
||||
|
||||
@main_bp.route("/delete_repository_files/<repo_type>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_repository_files(repo_type: str):
|
||||
label, source_dir = _get_repository_folder(repo_type)
|
||||
if not label or not source_dir:
|
||||
flash("알 수 없는 저장소입니다.", "warning")
|
||||
return redirect(url_for("main.index", workspace=repo_type))
|
||||
|
||||
deleted_count = 0
|
||||
skipped_count = 0
|
||||
for filename in request.form.getlist("files"):
|
||||
file_path = _safe_repo_file_path(source_dir, filename)
|
||||
if not file_path:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
file_path.unlink()
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
skipped_count += 1
|
||||
logging.error("%s 저장소 선택 파일 삭제 오류: %s -> %s", label, filename, e)
|
||||
|
||||
if deleted_count:
|
||||
message = f"{label} 파일 {deleted_count}개를 삭제했습니다."
|
||||
if skipped_count:
|
||||
message += f" {skipped_count}개는 건너뛰었습니다."
|
||||
flash(message, "success")
|
||||
else:
|
||||
flash("삭제할 파일을 선택하세요.", "warning")
|
||||
|
||||
return redirect(url_for("main.index", workspace=repo_type))
|
||||
|
||||
|
||||
@main_bp.route("/upload_workspace_files/<target_type>", methods=["POST"])
|
||||
@login_required
|
||||
def upload_workspace_files(target_type: str):
|
||||
if target_type == "server_info":
|
||||
folder_name = request.form.get("folder_name", "").strip()
|
||||
if not _is_safe_backup_folder_name(folder_name):
|
||||
flash("업로드할 서버 정보 폴더를 확인할 수 없습니다.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
base_dir = Path(Config.BACKUP_FOLDER).resolve()
|
||||
target_dir = (base_dir / folder_name).resolve()
|
||||
if base_dir not in target_dir.parents or not target_dir.is_dir():
|
||||
flash("업로드할 서버 정보 폴더가 존재하지 않습니다.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
label = f"서버 정보 '{folder_name}'"
|
||||
else:
|
||||
label, target_dir = _get_repository_folder(target_type)
|
||||
if not label or not target_dir:
|
||||
flash("알 수 없는 업로드 대상입니다.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
duplicate_policy = request.form.get("duplicate_policy", "rename")
|
||||
should_overwrite = duplicate_policy == "overwrite"
|
||||
uploaded_files = request.files.getlist("files")
|
||||
saved_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for uploaded in uploaded_files:
|
||||
filename = _safe_upload_filename(uploaded.filename)
|
||||
if not filename:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
destination = target_dir / filename if should_overwrite else _unique_upload_path(target_dir, filename)
|
||||
try:
|
||||
uploaded.save(destination)
|
||||
saved_count += 1
|
||||
except Exception as e:
|
||||
skipped_count += 1
|
||||
logging.error("워크스페이스 파일 업로드 실패: %s -> %s", filename, e)
|
||||
|
||||
if saved_count:
|
||||
message = f"{label}에 파일 {saved_count}개를 업로드했습니다."
|
||||
if skipped_count:
|
||||
message += f" {skipped_count}개는 건너뛰었습니다."
|
||||
flash(message, "success")
|
||||
else:
|
||||
flash("업로드할 파일을 선택하지 않았거나 저장할 수 없습니다.", "warning")
|
||||
|
||||
logging.info("워크스페이스 업로드 완료: target=%s saved=%s skipped=%s", target_type, saved_count, skipped_count)
|
||||
return redirect(url_for("main.index", workspace=target_type))
|
||||
@@ -0,0 +1,166 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
import logging
|
||||
import difflib
|
||||
from pathlib import Path
|
||||
from config import Config
|
||||
from backend.services.redfish_client import RedfishClient
|
||||
from backend.routes.xml import sanitize_preserve_unicode
|
||||
|
||||
scp_bp = Blueprint("scp", __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@scp_bp.route("/scp/diff", methods=["GET"])
|
||||
@login_required
|
||||
def diff_scp():
|
||||
"""
|
||||
두 XML 파일의 차이점을 비교하여 보여줍니다.
|
||||
"""
|
||||
file1_name = request.args.get("file1")
|
||||
file2_name = request.args.get("file2")
|
||||
|
||||
if not file1_name or not file2_name:
|
||||
flash("비교할 두 파일을 선택해주세요.", "warning")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
try:
|
||||
file1_path = Path(Config.XML_FOLDER) / sanitize_preserve_unicode(file1_name)
|
||||
file2_path = Path(Config.XML_FOLDER) / sanitize_preserve_unicode(file2_name)
|
||||
|
||||
if not file1_path.exists() or not file2_path.exists():
|
||||
flash("파일을 찾을 수 없습니다.", "danger")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
# 파일 내용 읽기 (LF로 통일)
|
||||
# 파일 내용 읽기 (LF로 통일)
|
||||
# Monaco Editor에 원본 텍스트를 그대로 전달하기 위해 splitlines() 제거
|
||||
# 파일 내용 읽기 (LF로 통일)
|
||||
logger.info(f"Reading file1: {file1_path}")
|
||||
content1 = file1_path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||
|
||||
logger.info(f"Reading file2: {file2_path}")
|
||||
content2 = file2_path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||
|
||||
logger.info(f"Content1 length: {len(content1)}, Content2 length: {len(content2)}")
|
||||
|
||||
return render_template("scp_diff.html",
|
||||
file1=file1_name,
|
||||
file2=file2_name,
|
||||
content1=content1,
|
||||
content2=content2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Diff error: {e}")
|
||||
flash(f"비교 중 오류가 발생했습니다: {str(e)}", "danger")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
@scp_bp.route("/scp/content/<path:filename>")
|
||||
@login_required
|
||||
def get_scp_content(filename):
|
||||
"""
|
||||
XML 파일 내용을 반환하는 API (Monaco Editor용)
|
||||
"""
|
||||
try:
|
||||
safe_name = sanitize_preserve_unicode(filename)
|
||||
path = Path(Config.XML_FOLDER) / safe_name
|
||||
|
||||
if not path.exists():
|
||||
return "File not found", 404
|
||||
|
||||
# 텍스트로 읽어서 반환
|
||||
content = path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||
return content, 200, {'Content-Type': 'text/plain; charset=utf-8'}
|
||||
except Exception as e:
|
||||
logger.error(f"Content read error: {e}")
|
||||
return str(e), 500
|
||||
|
||||
@scp_bp.route("/scp/export", methods=["POST"])
|
||||
@login_required
|
||||
def export_scp():
|
||||
"""
|
||||
iDRAC에서 설정을 내보내기 (Export)
|
||||
네트워크 공유 설정이 필요합니다.
|
||||
"""
|
||||
data = request.form
|
||||
target_ip = data.get("target_ip")
|
||||
username = data.get("username")
|
||||
password = data.get("password")
|
||||
|
||||
# Share Parameters
|
||||
share_ip = data.get("share_ip")
|
||||
share_name = data.get("share_name")
|
||||
share_user = data.get("share_user")
|
||||
share_pwd = data.get("share_pwd")
|
||||
filename = data.get("filename")
|
||||
|
||||
if not all([target_ip, username, password, share_ip, share_name, filename]):
|
||||
flash("필수 정보가 누락되었습니다.", "warning")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
share_params = {
|
||||
"IPAddress": share_ip,
|
||||
"ShareName": share_name,
|
||||
"FileName": filename,
|
||||
"ShareType": "CIFS", # 기본값 CIFS
|
||||
"UserName": share_user,
|
||||
"Password": share_pwd
|
||||
}
|
||||
|
||||
try:
|
||||
with RedfishClient(target_ip, username, password) as client:
|
||||
job_id = client.export_system_configuration(share_params)
|
||||
if job_id:
|
||||
flash(f"내보내기 작업이 시작되었습니다. Job ID: {job_id}", "success")
|
||||
else:
|
||||
flash("작업을 시작했으나 Job ID를 받지 못했습니다.", "warning")
|
||||
except Exception as e:
|
||||
logger.error(f"Export failed: {e}")
|
||||
flash(f"내보내기 실패: {str(e)}", "danger")
|
||||
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
@scp_bp.route("/scp/import", methods=["POST"])
|
||||
@login_required
|
||||
def import_scp():
|
||||
"""
|
||||
iDRAC로 설정 가져오기 (Import/Deploy)
|
||||
"""
|
||||
data = request.form
|
||||
target_ip = data.get("target_ip")
|
||||
username = data.get("username")
|
||||
password = data.get("password")
|
||||
|
||||
# Share Parameters
|
||||
share_ip = data.get("share_ip")
|
||||
share_name = data.get("share_name")
|
||||
share_user = data.get("share_user")
|
||||
share_pwd = data.get("share_pwd")
|
||||
filename = data.get("filename")
|
||||
|
||||
import_mode = data.get("import_mode", "Replace")
|
||||
|
||||
if not all([target_ip, username, password, share_ip, share_name, filename]):
|
||||
flash("필수 정보가 누락되었습니다.", "warning")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
share_params = {
|
||||
"IPAddress": share_ip,
|
||||
"ShareName": share_name,
|
||||
"FileName": filename,
|
||||
"ShareType": "CIFS",
|
||||
"UserName": share_user,
|
||||
"Password": share_pwd
|
||||
}
|
||||
|
||||
try:
|
||||
with RedfishClient(target_ip, username, password) as client:
|
||||
job_id = client.import_system_configuration(share_params, import_mode=import_mode)
|
||||
if job_id:
|
||||
flash(f"설정 적용(Import) 작업이 시작되었습니다. Job ID: {job_id}", "success")
|
||||
else:
|
||||
flash("작업을 시작했으나 Job ID를 받지 못했습니다.", "warning")
|
||||
except Exception as e:
|
||||
logger.error(f"Import failed: {e}")
|
||||
flash(f"설정 적용 실패: {str(e)}", "danger")
|
||||
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
스크립트 관리 라우트 및 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>/<path: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>/<path: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>/<path: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>/export", methods=["GET"])
|
||||
@login_required
|
||||
def export_profile(category):
|
||||
"""프로파일 YAML 파일 다운로드"""
|
||||
name = request.args.get("name", "")
|
||||
if not name:
|
||||
return jsonify({"error": "프로파일 이름 필요"}), 400
|
||||
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
|
||||
|
||||
|
||||
@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)})
|
||||
|
||||
|
||||
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)}"})
|
||||
@@ -0,0 +1,721 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
import zipfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, request, redirect, url_for, flash, jsonify, send_file
|
||||
from flask_login import login_required
|
||||
from config import Config
|
||||
from backend.models.user import db
|
||||
from backend.models.system_setting import SystemSetting
|
||||
|
||||
utils_bp = Blueprint("utils", __name__)
|
||||
|
||||
|
||||
def get_system_setting(key: str, default: str = "") -> str:
|
||||
try:
|
||||
setting = db.session.get(SystemSetting, key)
|
||||
return setting.value if setting else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def register_util_routes(app):
|
||||
app.register_blueprint(utils_bp)
|
||||
|
||||
|
||||
@utils_bp.route("/move_mac_files", methods=["POST"])
|
||||
@login_required
|
||||
def move_mac_files():
|
||||
src = Path(Config.IDRAC_INFO_FOLDER)
|
||||
dst = Path(Config.MAC_FOLDER)
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
moved = 0
|
||||
skipped = 0
|
||||
missing = 0
|
||||
errors = []
|
||||
|
||||
# JSON 요청 파싱 (overwrite 플래그 확인)
|
||||
data = request.get_json(silent=True) or {}
|
||||
overwrite = data.get("overwrite", False)
|
||||
|
||||
# 1. 대상 파일 스냅샷 (이동 시도할, 또는 해야할 파일들)
|
||||
try:
|
||||
current_files = [f for f in src.iterdir() if f.is_file()]
|
||||
except Exception as e:
|
||||
logging.error(f"파일 목록 조회 실패: {e}")
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
|
||||
# [중복 체크 로직] 덮어쓰기 모드가 아닐 때, 미리 중복 검사
|
||||
if not overwrite:
|
||||
duplicates = []
|
||||
for file in current_files:
|
||||
target = dst / file.name
|
||||
if target.exists():
|
||||
duplicates.append(file.name)
|
||||
|
||||
if duplicates:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"requires_confirmation": True,
|
||||
"duplicates": duplicates,
|
||||
"duplicate_count": len(duplicates)
|
||||
})
|
||||
else:
|
||||
logging.warning(f"⚠️ [MAC] 덮어쓰기 모드 활성화됨 - 중복 파일을 덮어씁니다.")
|
||||
|
||||
total_target_count = len(current_files)
|
||||
|
||||
# 카운터
|
||||
moved_count = 0 # 내가 직접 옮김 (또는 덮어씀)
|
||||
verified_count = 0 # 최종적으로 목적지에 있음을 확인 (성공)
|
||||
lost_count = 0 # 소스에도 없고 목적지에도 없음 (진짜 유실?)
|
||||
errors = []
|
||||
|
||||
for file in current_files:
|
||||
target = dst / file.name
|
||||
|
||||
# [Step 1] 이미 목적지에 있는지 확인
|
||||
if target.exists():
|
||||
if overwrite:
|
||||
# 덮어쓰기 모드: 기존 파일 삭제 후 이동 진행 (또는 바로 move로 덮어쓰기)
|
||||
# shutil.move는 대상이 존재하면 에러가 날 수 있으므로(버전/OS따라 다름), 안전하게 삭제 시도
|
||||
try:
|
||||
# Windows에서는 사용 중인 파일 삭제 시 에러 발생 가능
|
||||
# shutil.move(src, dst)는 dst가 존재하면 덮어쓰기 시도함 (Python 3.x)
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# (중복 체크를 통과했거나 Race Condition으로 생성된 경우) -> 이미 완료된 것으로 간주
|
||||
verified_count += 1
|
||||
logging.info(f"⏭️ 파일 이미 존재 (Skipped): {file.name}")
|
||||
continue
|
||||
|
||||
# [Step 2] 소스에 있는지 확인 (Race Condition)
|
||||
if not file.exists():
|
||||
if target.exists():
|
||||
verified_count += 1
|
||||
continue
|
||||
else:
|
||||
lost_count += 1
|
||||
logging.warning(f"❓ 이동 중 사라짐: {file.name}")
|
||||
continue
|
||||
|
||||
# [Step 3] 이동 시도 (덮어쓰기 포함)
|
||||
try:
|
||||
shutil.move(str(file), str(target))
|
||||
moved_count += 1
|
||||
verified_count += 1
|
||||
except shutil.Error as e:
|
||||
# shutil.move might raise Error if destination exists depending on implementation,
|
||||
# but standard behavior overwrites if not same file.
|
||||
# If exact same file, verified.
|
||||
if target.exists():
|
||||
verified_count += 1
|
||||
else:
|
||||
errors.append(f"{file.name}: {str(e)}")
|
||||
except FileNotFoundError:
|
||||
# 옮기려는 찰나에 사라짐 -> 목적지 재확인
|
||||
if target.exists():
|
||||
verified_count += 1
|
||||
logging.info(f"⏭️ 동시 처리됨 (완료): {file.name}")
|
||||
else:
|
||||
lost_count += 1
|
||||
except Exception as e:
|
||||
# 권한 에러 등 진짜 실패
|
||||
error_msg = f"{file.name}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
logging.error(f"❌ 이동 에러: {error_msg}")
|
||||
|
||||
# 결과 요약
|
||||
msg = f"총 {total_target_count}건 중 {verified_count}건 처리 완료"
|
||||
if moved_count < verified_count:
|
||||
msg += f" (이동: {moved_count}, 이미 완료: {verified_count - moved_count})"
|
||||
|
||||
if lost_count > 0:
|
||||
msg += f", 확인 불가: {lost_count}"
|
||||
|
||||
logging.info(f"✅ MAC 처리 결과: {msg}")
|
||||
flash(msg, "success" if lost_count == 0 else "warning")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"total": total_target_count,
|
||||
"verified": verified_count,
|
||||
"message": msg,
|
||||
"errors": errors if errors else None
|
||||
})
|
||||
|
||||
|
||||
@utils_bp.route("/move_guid_files", methods=["POST"])
|
||||
@login_required
|
||||
def move_guid_files():
|
||||
src = Path(Config.IDRAC_INFO_FOLDER)
|
||||
dst = Path(Config.GUID_FOLDER)
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
moved = 0
|
||||
skipped = 0
|
||||
missing = 0
|
||||
errors = []
|
||||
|
||||
# JSON 요청 파싱 (overwrite 플래그 확인)
|
||||
data = request.get_json(silent=True) or {}
|
||||
overwrite = data.get("overwrite", False)
|
||||
|
||||
try:
|
||||
files = [f for f in src.iterdir() if f.is_file()]
|
||||
except Exception:
|
||||
files = []
|
||||
|
||||
# [중복 체크]
|
||||
if not overwrite:
|
||||
duplicates = []
|
||||
for file in files:
|
||||
target = dst / file.name
|
||||
if target.exists():
|
||||
duplicates.append(file.name)
|
||||
if duplicates:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"requires_confirmation": True,
|
||||
"duplicates": duplicates,
|
||||
"duplicate_count": len(duplicates)
|
||||
})
|
||||
else:
|
||||
logging.warning(f"⚠️ [GUID] 덮어쓰기 모드 활성화됨 - 중복 파일을 덮어씁니다.")
|
||||
|
||||
total_target_count = len(files)
|
||||
verified_count = 0
|
||||
moved_count = 0
|
||||
errors = []
|
||||
lost_count = 0
|
||||
|
||||
try:
|
||||
for file in files:
|
||||
target = dst / file.name
|
||||
|
||||
# 1. 이미 완료되었는지 확인
|
||||
if target.exists():
|
||||
if not overwrite:
|
||||
verified_count += 1
|
||||
continue
|
||||
# overwrite=True면 계속 진행하여 덮어쓰기 시도
|
||||
|
||||
# 2. 소스 확인
|
||||
if not file.exists():
|
||||
if target.exists(): verified_count += 1
|
||||
else: lost_count += 1
|
||||
continue
|
||||
|
||||
# 3. 이동
|
||||
try:
|
||||
shutil.move(str(file), str(target))
|
||||
moved_count += 1
|
||||
verified_count += 1
|
||||
except FileNotFoundError:
|
||||
if target.exists(): verified_count += 1
|
||||
else: lost_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"{file.name}: {e}")
|
||||
|
||||
# 상세 메시지
|
||||
msg = f"총 {total_target_count}건 중 {verified_count}건 처리 완료"
|
||||
logging.info(f"✅ GUID 처리: {msg}")
|
||||
flash(msg, "success" if lost_count == 0 else "warning")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"total": total_target_count,
|
||||
"verified": verified_count,
|
||||
"message": msg,
|
||||
"errors": errors if errors else None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"❌ GUID 이동 중 오류: {e}")
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
|
||||
@utils_bp.route("/move_gpu_files", methods=["POST"])
|
||||
@login_required
|
||||
def move_gpu_files():
|
||||
"""
|
||||
data/idrac_info → data/gpu_serial 로 GPU 시리얼 텍스트 파일 이동
|
||||
"""
|
||||
src = Path(Config.IDRAC_INFO_FOLDER) # 예: data/idrac_info
|
||||
dst = Path(Config.GPU_FOLDER) # 예: data/gpu_serial
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
moved = 0
|
||||
skipped = 0
|
||||
missing = 0
|
||||
errors = []
|
||||
|
||||
# JSON 요청 파싱
|
||||
data = request.get_json(silent=True) or {}
|
||||
overwrite = data.get("overwrite", False)
|
||||
|
||||
try:
|
||||
files = [f for f in src.iterdir() if f.is_file()]
|
||||
except Exception:
|
||||
files = []
|
||||
|
||||
# [중복 체크]
|
||||
if not overwrite:
|
||||
duplicates = []
|
||||
for file in files:
|
||||
target = dst / file.name
|
||||
if target.exists():
|
||||
duplicates.append(file.name)
|
||||
if duplicates:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"requires_confirmation": True,
|
||||
"duplicates": duplicates,
|
||||
"duplicate_count": len(duplicates)
|
||||
})
|
||||
else:
|
||||
logging.warning(f"⚠️ [GPU] 덮어쓰기 모드 활성화됨 - 중복 파일을 덮어씁니다.")
|
||||
|
||||
total_target_count = len(files)
|
||||
verified_count = 0
|
||||
moved_count = 0
|
||||
errors = []
|
||||
lost_count = 0
|
||||
|
||||
try:
|
||||
for file in files:
|
||||
target = dst / file.name
|
||||
|
||||
# 1. 존재 확인 (덮어쓰기 아닐 경우)
|
||||
if target.exists():
|
||||
if not overwrite:
|
||||
verified_count += 1
|
||||
continue
|
||||
|
||||
# 2. 소스 확인
|
||||
if not file.exists():
|
||||
if target.exists(): verified_count += 1
|
||||
else: lost_count += 1
|
||||
continue
|
||||
|
||||
# 3. 이동
|
||||
try:
|
||||
shutil.move(str(file), str(target))
|
||||
moved_count += 1
|
||||
verified_count += 1
|
||||
except FileNotFoundError:
|
||||
if target.exists(): verified_count += 1
|
||||
else: lost_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"{file.name}: {e}")
|
||||
|
||||
# 상세 메시지
|
||||
msg = f"총 {total_target_count}건 중 {verified_count}건 처리 완료"
|
||||
logging.info(f"✅ GPU 처리: {msg}")
|
||||
flash(msg, "success")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"total": total_target_count,
|
||||
"verified": verified_count,
|
||||
"message": msg,
|
||||
"errors": errors if errors else None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"❌ GPU 이동 오류: {e}")
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
|
||||
@utils_bp.route("/update_server_list", methods=["POST"])
|
||||
@login_required
|
||||
def update_server_list():
|
||||
content = request.form.get("server_list_content")
|
||||
if not content:
|
||||
flash("내용을 입력하세요.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
path = Path(Config.SERVER_LIST_FOLDER) / "server_list.txt"
|
||||
try:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(Path(Config.SERVER_LIST_FOLDER) / "unified_excel_generator.py"), "--mode", "mac"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=str(Path(Config.SERVER_LIST_FOLDER)),
|
||||
timeout=300,
|
||||
)
|
||||
logging.info(f"서버 리스트 스크립트 실행 결과: {result.stdout}")
|
||||
flash("서버 리스트가 업데이트되었습니다.", "success")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"서버 리스트 스크립트 오류: {e.stderr}")
|
||||
flash(f"스크립트 실행 실패: {e.stderr}", "danger")
|
||||
except Exception as e:
|
||||
logging.error(f"서버 리스트 처리 오류: {e}")
|
||||
flash(f"서버 리스트 처리 중 오류 발생: {e}", "danger")
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@utils_bp.route("/update_guid_list", methods=["POST"])
|
||||
@login_required
|
||||
def update_guid_list():
|
||||
content = request.form.get("server_list_content")
|
||||
slot_priority = request.form.get("slot_priority", "") # 슬롯 우선순위 받기
|
||||
|
||||
if not content:
|
||||
flash("내용을 입력하세요.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
path = Path(Config.SERVER_LIST_FOLDER) / "guid_list.txt"
|
||||
try:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
# 슬롯 우선순위를 환경변수로 전달
|
||||
env = os.environ.copy()
|
||||
if slot_priority:
|
||||
env["GUID_SLOT_PRIORITY"] = slot_priority
|
||||
logging.info(f"GUID 슬롯 우선순위: {slot_priority}")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(Path(Config.SERVER_LIST_FOLDER) / "unified_excel_generator.py"), "--mode", "guid"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=str(Path(Config.SERVER_LIST_FOLDER)),
|
||||
timeout=300,
|
||||
env=env, # 환경변수 전달
|
||||
)
|
||||
logging.info(f"GUID 리스트 스크립트 실행 결과: {result.stdout}")
|
||||
flash("GUID 리스트가 업데이트되었습니다.", "success")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"GUID 리스트 스크립트 오류: {e.stderr}")
|
||||
flash(f"스크립트 실행 실패: {e.stderr}", "danger")
|
||||
except Exception as e:
|
||||
logging.error(f"GUID 리스트 처리 오류: {e}")
|
||||
flash(f"GUID 리스트 처리 중 오류 발생: {e}", "danger")
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
@utils_bp.route("/update_gpu_list", methods=["POST"])
|
||||
@login_required
|
||||
def update_gpu_list():
|
||||
"""
|
||||
GPU 시리얼용 리스트(gpu_serial_list.txt)를 갱신하고 Excel을 생성합니다.
|
||||
- form name="gpu_list_content" 로 내용 전달 (S/T 목록 라인별)
|
||||
- txt_to_excel.py --preset gpu --list-file <gpu_serial_list.txt>
|
||||
"""
|
||||
content = request.form.get("server_list_content")
|
||||
if not content:
|
||||
flash("내용을 입력하세요.", "warning")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
server_list_dir = Path(Config.SERVER_LIST_FOLDER)
|
||||
list_path = server_list_dir / "gpu_list.txt"
|
||||
# txt_to_excel.py는 server_list 폴더에 둔다고 가정 (위치 다르면 경로만 수정)
|
||||
script_path = server_list_dir / "GPUTOExecl.py"
|
||||
|
||||
try:
|
||||
# 1) gpu_serial_list.txt 저장
|
||||
list_path.write_text(content, encoding="utf-8")
|
||||
|
||||
# 2) 엑셀 생성 실행 (GPU 프리셋)
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(Path(Config.SERVER_LIST_FOLDER) / "unified_excel_generator.py"),
|
||||
"--mode", "gpu",
|
||||
"--list-file", str(list_path),
|
||||
]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=str(server_list_dir), # data/server_list 기준 실행
|
||||
timeout=300,
|
||||
)
|
||||
logging.info(f"[GPU] 리스트 스크립트 실행 STDOUT:\n{result.stdout}")
|
||||
if result.stderr:
|
||||
logging.warning(f"[GPU] 리스트 스크립트 STDERR:\n{result.stderr}")
|
||||
|
||||
flash("GPU 리스트가 업데이트되었습니다.", "success")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"[GPU] 스크립트 오류: {e.stderr}")
|
||||
flash(f"스크립트 실행 실패: {e.stderr}", "danger")
|
||||
except Exception as e:
|
||||
logging.error(f"[GPU] 처리 오류: {e}")
|
||||
flash(f"GPU 리스트 처리 중 오류 발생: {e}", "danger")
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
logging.info(f"엑셀 파일 다운로드: {path}")
|
||||
return send_file(str(path), as_attachment=True, download_name="mac_info.xlsx")
|
||||
|
||||
|
||||
@utils_bp.route("/scan_network", methods=["POST"])
|
||||
@login_required
|
||||
def scan_network():
|
||||
"""
|
||||
지정된 IP 범위(Start ~ End)에 대해 Ping 테스트를 수행하고
|
||||
응답이 있는 IP 목록을 반환합니다.
|
||||
"""
|
||||
try:
|
||||
import ipaddress
|
||||
import platform
|
||||
import concurrent.futures
|
||||
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
start_ip_str = data.get('start_ip')
|
||||
end_ip_str = data.get('end_ip')
|
||||
|
||||
if not start_ip_str or not end_ip_str:
|
||||
return jsonify({"success": False, "error": "시작 IP와 종료 IP를 모두 입력해주세요."}), 400
|
||||
|
||||
try:
|
||||
start_ip = ipaddress.IPv4Address(start_ip_str)
|
||||
end_ip = ipaddress.IPv4Address(end_ip_str)
|
||||
|
||||
if start_ip > end_ip:
|
||||
return jsonify({"success": False, "error": "시작 IP가 종료 IP보다 큽니다."}), 400
|
||||
|
||||
# IP 개수 제한 (너무 많은 스캔 방지, 예: C클래스 2개 분량 512개)
|
||||
if int(end_ip) - int(start_ip) > 512:
|
||||
return jsonify({"success": False, "error": "스캔 범위가 너무 넓습니다. (최대 512개)"}), 400
|
||||
|
||||
except ValueError:
|
||||
return jsonify({"success": False, "error": "유효하지 않은 IP 주소 형식입니다."}), 400
|
||||
|
||||
# Ping 함수 정의
|
||||
def ping_ip(ip_obj):
|
||||
ip = str(ip_obj)
|
||||
param = '-n' if platform.system().lower() == 'windows' else '-c'
|
||||
timeout_param = '-w' if platform.system().lower() == 'windows' else '-W'
|
||||
# Windows: -w 200 (ms), Linux: -W 1 (s)
|
||||
timeout_val = '200' if platform.system().lower() == 'windows' else '1'
|
||||
|
||||
command = ['ping', param, '1', timeout_param, timeout_val, ip]
|
||||
|
||||
try:
|
||||
# shell=False로 보안 강화, stdout/stderr 무시
|
||||
res = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return ip if res.returncode == 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
active_ips = []
|
||||
|
||||
# IP 리스트 생성
|
||||
target_ips = []
|
||||
temp_ip = start_ip
|
||||
while temp_ip <= end_ip:
|
||||
target_ips.append(temp_ip)
|
||||
temp_ip += 1
|
||||
|
||||
# 병렬 처리 (최대 50 쓰레드)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
|
||||
results = executor.map(ping_ip, target_ips)
|
||||
|
||||
# 결과 수집 (None 제외)
|
||||
active_ips = [ip for ip in results if ip is not None]
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"active_ips": active_ips,
|
||||
"count": len(active_ips),
|
||||
"message": f"스캔 완료: {len(active_ips)}개의 활성 IP 발견"
|
||||
})
|
||||
|
||||
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]
|
||||
env = os.environ.copy()
|
||||
if action == "tsr_save":
|
||||
env["TSR_SHARE_URL"] = get_system_setting("tsr_share_url", "//10.10.3.15/share/")
|
||||
env["OME_USER"] = get_system_setting("tsr_share_user", env.get("OME_USER", ""))
|
||||
env["OME_PASS"] = get_system_setting("tsr_share_password", env.get("OME_PASS", ""))
|
||||
|
||||
logging.info(f"[SystemControl] Executing {action} with {len(ips)} IPs (Unified Script)...")
|
||||
|
||||
# 타임아웃 넉넉하게 (작업에 따라 다름)
|
||||
timeout = 1200 if action == "tsr_save" else 600 if action == "tsr_collect" else 300
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False, # 에러 발생해도 결과 받음
|
||||
timeout=timeout,
|
||||
env=env
|
||||
)
|
||||
|
||||
# 임시 파일 삭제
|
||||
try:
|
||||
os.remove(temp_ip_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
output = "\n".join(
|
||||
part for part in [result.stdout.strip(), result.stderr.strip()] if part
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logging.info(f"[SystemControl] Success: {output}")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"작업({action})이 성공적으로 완료되었습니다.",
|
||||
"details": output
|
||||
})
|
||||
else:
|
||||
logging.error(f"[SystemControl] Failed: {output}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"스크립트 실행 중 오류가 발생했습니다.\n{output}"
|
||||
}), 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
|
||||
|
||||
|
||||
@utils_bp.route("/api/export_by_service_tag", methods=["POST"])
|
||||
@login_required
|
||||
def export_by_service_tag():
|
||||
"""
|
||||
서비스태그 목록(텍스트 입력 또는 .txt 파일)을 받아
|
||||
하위 폴더에서 <서비스태그>.txt 파일을 검색하고 ZIP으로 묶어 다운로드.
|
||||
"""
|
||||
tags_text = request.form.get("service_tags", "").strip()
|
||||
tag_file = request.files.get("tag_file")
|
||||
|
||||
if tag_file and tag_file.filename:
|
||||
try:
|
||||
tags_text = tag_file.read().decode("utf-8", errors="ignore")
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": f"파일 읽기 실패: {e}"}), 400
|
||||
|
||||
if not tags_text:
|
||||
return jsonify({"success": False, "error": "서비스태그를 입력하거나 .txt 파일을 업로드하세요."}), 400
|
||||
|
||||
# 서비스태그 파싱 (줄 단위, 중복 제거, 공백 제거)
|
||||
tags = list(dict.fromkeys(
|
||||
line.strip() for line in tags_text.splitlines() if line.strip()
|
||||
))
|
||||
|
||||
if not tags:
|
||||
return jsonify({"success": False, "error": "유효한 서비스태그가 없습니다."}), 400
|
||||
|
||||
# 검색 대상 디렉토리 (재귀 검색)
|
||||
search_dirs = [
|
||||
Path(Config.BACKUP_FOLDER), # data/system/backup (날짜 하위폴더 포함)
|
||||
Path(Config.MAC_FOLDER), # data/repository/mac
|
||||
Path(Config.GUID_FOLDER), # data/repository/guid_file
|
||||
Path(Config.GPU_FOLDER), # data/repository/gpu_serial
|
||||
Path(Config.IDRAC_INFO_FOLDER), # data/temp/staging
|
||||
]
|
||||
|
||||
found = [] # (Path, arcname) 튜플 목록
|
||||
not_found = []
|
||||
|
||||
for tag in tags:
|
||||
target_name = f"{tag}.txt"
|
||||
tag_found = False
|
||||
for search_dir in search_dirs:
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
matches = list(search_dir.rglob(target_name))
|
||||
if matches:
|
||||
# 검색된 파일만, 파일명 그대로 (flat 구조)
|
||||
found.append((matches[0], matches[0].name))
|
||||
tag_found = True
|
||||
break
|
||||
if not tag_found:
|
||||
not_found.append(tag)
|
||||
|
||||
if not found:
|
||||
msg = f"검색된 파일이 없습니다. ({len(not_found)}개 태그)"
|
||||
return jsonify({"success": False, "error": msg, "not_found": not_found}), 404
|
||||
|
||||
# 사용자 지정 파일명 처리
|
||||
user_zip_name = request.form.get("zip_name", "").strip()
|
||||
# 위험 문자 제거
|
||||
import re
|
||||
safe_name = re.sub(r'[\\/:*?"<>|]', '_', user_zip_name) if user_zip_name else ""
|
||||
if not safe_name:
|
||||
safe_name = f"service_tag_export_{time.strftime('%Y%m%d_%H%M%S')}"
|
||||
zip_name = safe_name if safe_name.endswith(".zip") else safe_name + ".zip"
|
||||
zip_path = Path(Config.TEMP_ZIP_FOLDER) / zip_name
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf:
|
||||
for file_path, arcname in found:
|
||||
# writestr 사용 → 폴더 구조 없이 파일명만 ZIP에 저장
|
||||
with open(file_path, "rb") as f:
|
||||
zipf.writestr(arcname, f.read())
|
||||
|
||||
not_found_header = ",".join(not_found) if not_found else ""
|
||||
response = send_file(
|
||||
str(zip_path),
|
||||
as_attachment=True,
|
||||
download_name=zip_name,
|
||||
mimetype="application/zip",
|
||||
)
|
||||
response.headers["X-Found-Count"] = str(len(found))
|
||||
response.headers["X-Not-Found-Count"] = str(len(not_found))
|
||||
response.headers["X-Not-Found-Tags"] = not_found_header
|
||||
response.headers["Access-Control-Expose-Headers"] = (
|
||||
"X-Found-Count, X-Not-Found-Count, X-Not-Found-Tags"
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
logging.error(f"[ServiceTagExport] ZIP 생성 오류: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
finally:
|
||||
try:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
except Exception as e:
|
||||
logging.warning(f"임시 ZIP 삭제 실패: {e}")
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required
|
||||
from config import Config
|
||||
|
||||
xml_bp = Blueprint("xml", __name__)
|
||||
|
||||
|
||||
def register_xml_routes(app):
|
||||
app.register_blueprint(xml_bp)
|
||||
|
||||
|
||||
def allowed_file(filename: str) -> bool:
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in Config.ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def sanitize_preserve_unicode(filename: str) -> str:
|
||||
"""
|
||||
디렉터리 탐색/제어 문자를 차단하면서, 한글/유니코드 파일명은 그대로 보존합니다.
|
||||
- 경로 요소 제거 (Path(...).name)
|
||||
- 유니코드 정규화(NFC)로 OS간 차이 최소화
|
||||
- 널문자/슬래시/역슬래시 차단
|
||||
"""
|
||||
name = Path(filename).name
|
||||
name = unicodedata.normalize("NFC", name)
|
||||
if not name or any(ch in name for ch in ["\x00", "/", "\\"]):
|
||||
raise ValueError("잘못된 파일명입니다.")
|
||||
return name
|
||||
|
||||
|
||||
@xml_bp.route("/xml_management")
|
||||
@login_required
|
||||
def xml_management():
|
||||
xml_dir = Path(Config.XML_FOLDER)
|
||||
try:
|
||||
files = sorted([f.name for f in xml_dir.iterdir() if f.is_file()])
|
||||
except FileNotFoundError:
|
||||
files = []
|
||||
flash("XML 폴더가 존재하지 않습니다.", "danger")
|
||||
return render_template("manage_xml.html", xml_files=files)
|
||||
|
||||
|
||||
@xml_bp.route("/upload_xml", methods=["POST"])
|
||||
@login_required
|
||||
def upload_xml():
|
||||
file = request.files.get("xmlFile")
|
||||
if not file or not file.filename:
|
||||
flash("업로드할 파일을 선택하세요.", "warning")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
if not allowed_file(file.filename):
|
||||
flash("XML 확장자만 업로드할 수 있습니다.", "warning")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
try:
|
||||
filename = sanitize_preserve_unicode(file.filename) # 한글/유니코드 보존
|
||||
except ValueError:
|
||||
flash("파일명이 올바르지 않습니다.", "danger")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
save_path = Path(Config.XML_FOLDER) / filename
|
||||
try:
|
||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.save(str(save_path))
|
||||
# 텍스트 파일 권장 권한 (Windows에서는 무시될 수 있음)
|
||||
try:
|
||||
save_path.chmod(0o644)
|
||||
except Exception:
|
||||
pass
|
||||
logging.info(f"XML 업로드됨: {filename}")
|
||||
flash("파일이 성공적으로 업로드되었습니다.", "success")
|
||||
except Exception as e:
|
||||
logging.error(f"파일 업로드 오류: {e}")
|
||||
flash("파일 저장 중 오류가 발생했습니다.", "danger")
|
||||
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
|
||||
@xml_bp.route("/delete_xml/<path:filename>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_xml(filename: str):
|
||||
try:
|
||||
safe_name = sanitize_preserve_unicode(filename)
|
||||
except ValueError:
|
||||
flash("잘못된 파일명입니다.", "danger")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
path = Path(Config.XML_FOLDER) / safe_name
|
||||
if not path.exists():
|
||||
flash("해당 파일이 존재하지 않습니다.", "warning")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
try:
|
||||
path.unlink()
|
||||
flash(f"{safe_name} 파일이 삭제되었습니다.", "success")
|
||||
logging.info(f"XML 삭제됨: {safe_name}")
|
||||
except Exception as e:
|
||||
logging.error(f"XML 삭제 오류: {e}")
|
||||
flash("파일 삭제 중 오류 발생", "danger")
|
||||
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
|
||||
@xml_bp.route("/edit_xml/<path:filename>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_xml(filename: str):
|
||||
try:
|
||||
safe_name = sanitize_preserve_unicode(filename)
|
||||
except ValueError:
|
||||
flash("잘못된 파일명입니다.", "danger")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
path = Path(Config.XML_FOLDER) / safe_name
|
||||
if not path.exists():
|
||||
flash("파일을 찾을 수 없습니다.", "danger")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
|
||||
if request.method == "POST":
|
||||
raw = request.form.get("content", "")
|
||||
|
||||
# 1) 개행 통일: CRLF/CR → LF
|
||||
normalized = raw.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
try:
|
||||
# 2) 항상 LF로 저장 (Windows에서도 강제)
|
||||
path.write_text(normalized, encoding="utf-8", newline="\n")
|
||||
logging.info(f"XML 수정됨: {safe_name}")
|
||||
flash("파일이 성공적으로 수정되었습니다.", "success")
|
||||
return redirect(url_for("xml.xml_management"))
|
||||
except Exception as e:
|
||||
logging.error(f"XML 저장 실패: {e}")
|
||||
flash("파일 저장 중 오류가 발생했습니다.", "danger")
|
||||
|
||||
try:
|
||||
# 보기/편집 일관성을 위해 읽을 때도 LF로 맞춰서 textarea에 넣음
|
||||
content = path.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n")
|
||||
except Exception as e:
|
||||
logging.error(f"XML 열기 실패: {e}")
|
||||
flash("파일 열기 중 오류가 발생했습니다.", "danger")
|
||||
content = ""
|
||||
|
||||
return render_template("edit_xml.html", filename=safe_name, content=content)
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Dell 펌웨어 카탈로그 대안 방법들
|
||||
backend/services/dell_catalog_alternatives.py
|
||||
|
||||
Dell의 공식 Catalog.xml이 차단된 경우 사용할 수 있는 대안들
|
||||
"""
|
||||
|
||||
import requests
|
||||
from typing import List, Dict, Optional
|
||||
from backend.models.firmware_version import FirmwareVersion, db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class DellFirmwareCatalogAlternatives:
|
||||
"""Dell 펌웨어 정보를 가져오는 대안 방법들"""
|
||||
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
|
||||
# ========================================
|
||||
# 방법 1: Dell Support API (공식 API)
|
||||
# ========================================
|
||||
|
||||
def fetch_from_dell_support_api(self, model: str = "PowerEdge R750") -> List[Dict]:
|
||||
"""
|
||||
Dell Support API를 통한 펌웨어 정보 조회
|
||||
|
||||
Dell의 공식 Support API 엔드포인트:
|
||||
https://www.dell.com/support/home/api/
|
||||
|
||||
참고: API 키가 필요할 수 있음
|
||||
"""
|
||||
try:
|
||||
# Dell Support API 엔드포인트 (예시)
|
||||
# 실제 API는 Dell 개발자 포털에서 확인 필요
|
||||
url = f"https://www.dell.com/support/home/api/products/{model}/drivers"
|
||||
|
||||
response = self.session.get(url, timeout=30)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return self._parse_support_api_response(data, model)
|
||||
else:
|
||||
print(f"Dell Support API 오류: {response.status_code}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"Dell Support API 조회 실패: {str(e)}")
|
||||
return []
|
||||
|
||||
# ========================================
|
||||
# 방법 2: Dell TechDirect (파트너 전용)
|
||||
# ========================================
|
||||
|
||||
def fetch_from_techdirect(self, model: str = "PowerEdge R750") -> List[Dict]:
|
||||
"""
|
||||
Dell TechDirect API를 통한 펌웨어 정보 조회
|
||||
|
||||
TechDirect는 Dell 파트너용 플랫폼
|
||||
API 키 필요: https://techdirect.dell.com/
|
||||
"""
|
||||
# TechDirect API 구현
|
||||
# 실제 사용 시 API 키 필요
|
||||
pass
|
||||
|
||||
# ========================================
|
||||
# 방법 3: 로컬 카탈로그 파일 사용
|
||||
# ========================================
|
||||
|
||||
def load_from_local_catalog(self, catalog_path: str) -> List[Dict]:
|
||||
"""
|
||||
로컬에 저장된 Catalog.xml 파일 사용
|
||||
|
||||
사용법:
|
||||
1. Dell 공식 사이트에서 수동으로 Catalog.xml 다운로드
|
||||
2. 로컬에 저장
|
||||
3. 이 함수로 파싱
|
||||
|
||||
Args:
|
||||
catalog_path: 로컬 Catalog.xml 파일 경로
|
||||
"""
|
||||
try:
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
with open(catalog_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
root = ET.fromstring(content)
|
||||
return self._parse_catalog_xml(root)
|
||||
|
||||
except Exception as e:
|
||||
print(f"로컬 카탈로그 파일 로드 실패: {str(e)}")
|
||||
return []
|
||||
|
||||
# ========================================
|
||||
# 방법 4: iDRAC에서 직접 조회
|
||||
# ========================================
|
||||
|
||||
def fetch_from_idrac(self, idrac_ip: str, username: str, password: str) -> List[Dict]:
|
||||
"""
|
||||
iDRAC Redfish API를 통해 사용 가능한 업데이트 조회
|
||||
|
||||
iDRAC는 Dell Update Service를 통해 사용 가능한 업데이트를 확인할 수 있음
|
||||
|
||||
장점:
|
||||
- 실제 서버에 맞는 정확한 펌웨어 정보
|
||||
- 외부 카탈로그 불필요
|
||||
|
||||
단점:
|
||||
- 각 서버마다 조회 필요
|
||||
- 네트워크 연결 필요
|
||||
"""
|
||||
try:
|
||||
from backend.services.idrac_redfish_client import DellRedfishClient
|
||||
|
||||
client = DellRedfishClient(idrac_ip, username, password)
|
||||
|
||||
# UpdateService에서 사용 가능한 업데이트 조회
|
||||
url = f"https://{idrac_ip}/redfish/v1/UpdateService"
|
||||
response = client.session.get(url, timeout=30)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# UpdateService의 Actions 확인
|
||||
# SimpleUpdate 또는 CheckForUpdate 액션 사용
|
||||
return self._parse_idrac_updates(data)
|
||||
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"iDRAC 업데이트 조회 실패: {str(e)}")
|
||||
return []
|
||||
|
||||
# ========================================
|
||||
# 방법 5: 수동 입력 (가장 간단하고 확실)
|
||||
# ========================================
|
||||
|
||||
def create_manual_catalog(self) -> List[Dict]:
|
||||
"""
|
||||
수동으로 작성한 펌웨어 버전 정보
|
||||
|
||||
Dell 공식 사이트에서 확인한 최신 버전을 수동으로 입력
|
||||
https://www.dell.com/support/
|
||||
|
||||
장점:
|
||||
- 가장 확실하고 안정적
|
||||
- 외부 의존성 없음
|
||||
- 검증된 버전만 사용
|
||||
|
||||
단점:
|
||||
- 수동 업데이트 필요
|
||||
"""
|
||||
manual_catalog = [
|
||||
{
|
||||
'component_name': 'BIOS',
|
||||
'latest_version': '2.15.2',
|
||||
'server_model': 'PowerEdge R750',
|
||||
'vendor': 'Dell',
|
||||
'release_date': '2024-03-15',
|
||||
'download_url': 'https://www.dell.com/support/home/drivers/driversdetails?driverid=...',
|
||||
'notes': 'PowerEdge R750 BIOS - 2024년 3월 릴리즈',
|
||||
'is_critical': False
|
||||
},
|
||||
{
|
||||
'component_name': 'iDRAC',
|
||||
'latest_version': '7.00.00.00',
|
||||
'server_model': None, # 모든 모델
|
||||
'vendor': 'Dell',
|
||||
'release_date': '2024-02-20',
|
||||
'download_url': 'https://www.dell.com/support/home/drivers/driversdetails?driverid=...',
|
||||
'notes': 'iDRAC9 최신 펌웨어 (14G/15G/16G 공용)',
|
||||
'is_critical': True
|
||||
},
|
||||
{
|
||||
'component_name': 'PERC H755',
|
||||
'latest_version': '25.5.9.0001',
|
||||
'server_model': 'PowerEdge R750',
|
||||
'vendor': 'Dell',
|
||||
'release_date': '2024-01-10',
|
||||
'notes': 'PERC H755 RAID 컨트롤러',
|
||||
'is_critical': False
|
||||
},
|
||||
# 더 많은 컴포넌트 추가...
|
||||
]
|
||||
|
||||
return manual_catalog
|
||||
|
||||
# ========================================
|
||||
# 방법 6: Dell Repository Manager (DRM)
|
||||
# ========================================
|
||||
|
||||
def fetch_from_drm_export(self, drm_export_path: str) -> List[Dict]:
|
||||
"""
|
||||
Dell Repository Manager (DRM)에서 내보낸 데이터 사용
|
||||
|
||||
DRM은 Dell의 공식 펌웨어 관리 도구
|
||||
https://www.dell.com/support/kbdoc/en-us/000177083/
|
||||
|
||||
사용법:
|
||||
1. DRM 설치 및 실행
|
||||
2. 필요한 서버 모델 선택
|
||||
3. 카탈로그 내보내기
|
||||
4. 내보낸 파일을 이 함수로 파싱
|
||||
"""
|
||||
try:
|
||||
# DRM 내보내기 파일 파싱 로직
|
||||
# XML 또는 CSV 형식일 수 있음
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"DRM 내보내기 파일 로드 실패: {str(e)}")
|
||||
return []
|
||||
|
||||
# ========================================
|
||||
# 헬퍼 함수들
|
||||
# ========================================
|
||||
|
||||
def _parse_support_api_response(self, data: Dict, model: str) -> List[Dict]:
|
||||
"""Dell Support API 응답 파싱"""
|
||||
# API 응답 구조에 따라 구현
|
||||
return []
|
||||
|
||||
def _parse_catalog_xml(self, root) -> List[Dict]:
|
||||
"""Catalog.xml 파싱"""
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
firmware_list = []
|
||||
|
||||
for pkg in root.findall(".//SoftwareComponent"):
|
||||
name = pkg.findtext("Name")
|
||||
version = pkg.findtext("Version")
|
||||
release_date = pkg.findtext("ReleaseDate")
|
||||
path = pkg.findtext("path")
|
||||
|
||||
if name and version:
|
||||
firmware_list.append({
|
||||
'component_name': name,
|
||||
'latest_version': version,
|
||||
'release_date': release_date,
|
||||
'download_url': f"https://downloads.dell.com/{path}" if path else None,
|
||||
'vendor': 'Dell'
|
||||
})
|
||||
|
||||
return firmware_list
|
||||
|
||||
def _parse_idrac_updates(self, data: Dict) -> List[Dict]:
|
||||
"""iDRAC UpdateService 응답 파싱"""
|
||||
# UpdateService 데이터 파싱
|
||||
return []
|
||||
|
||||
# ========================================
|
||||
# DB에 저장
|
||||
# ========================================
|
||||
|
||||
def save_to_database(self, firmware_list: List[Dict]) -> int:
|
||||
"""
|
||||
펌웨어 목록을 데이터베이스에 저장
|
||||
|
||||
Returns:
|
||||
저장된 항목 수
|
||||
"""
|
||||
count = 0
|
||||
|
||||
for fw in firmware_list:
|
||||
# 중복 확인
|
||||
existing = FirmwareVersion.query.filter_by(
|
||||
component_name=fw.get('component_name'),
|
||||
latest_version=fw.get('latest_version'),
|
||||
server_model=fw.get('server_model')
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
version = FirmwareVersion(
|
||||
component_name=fw.get('component_name'),
|
||||
latest_version=fw.get('latest_version'),
|
||||
server_model=fw.get('server_model'),
|
||||
vendor=fw.get('vendor', 'Dell'),
|
||||
release_date=fw.get('release_date'),
|
||||
download_url=fw.get('download_url'),
|
||||
notes=fw.get('notes'),
|
||||
is_critical=fw.get('is_critical', False)
|
||||
)
|
||||
db.session.add(version)
|
||||
count += 1
|
||||
|
||||
db.session.commit()
|
||||
return count
|
||||
|
||||
|
||||
# ========================================
|
||||
# 사용 예시
|
||||
# ========================================
|
||||
|
||||
def sync_firmware_alternative(method: str = 'manual', **kwargs) -> Dict:
|
||||
"""
|
||||
대안 방법으로 펌웨어 정보 동기화
|
||||
|
||||
Args:
|
||||
method: 'manual', 'local_catalog', 'idrac', 'support_api' 중 선택
|
||||
**kwargs: 각 방법에 필요한 추가 인자
|
||||
|
||||
Returns:
|
||||
{'success': bool, 'count': int, 'message': str}
|
||||
"""
|
||||
catalog = DellFirmwareCatalogAlternatives()
|
||||
firmware_list = []
|
||||
|
||||
try:
|
||||
if method == 'manual':
|
||||
# 가장 권장: 수동으로 작성한 카탈로그
|
||||
firmware_list = catalog.create_manual_catalog()
|
||||
|
||||
elif method == 'local_catalog':
|
||||
# 로컬 Catalog.xml 파일 사용
|
||||
catalog_path = kwargs.get('catalog_path')
|
||||
if not catalog_path:
|
||||
return {'success': False, 'count': 0, 'message': 'catalog_path 필요'}
|
||||
firmware_list = catalog.load_from_local_catalog(catalog_path)
|
||||
|
||||
elif method == 'idrac':
|
||||
# iDRAC에서 직접 조회
|
||||
idrac_ip = kwargs.get('idrac_ip')
|
||||
username = kwargs.get('username')
|
||||
password = kwargs.get('password')
|
||||
if not all([idrac_ip, username, password]):
|
||||
return {'success': False, 'count': 0, 'message': 'iDRAC 정보 필요'}
|
||||
firmware_list = catalog.fetch_from_idrac(idrac_ip, username, password)
|
||||
|
||||
elif method == 'support_api':
|
||||
# Dell Support API 사용
|
||||
model = kwargs.get('model', 'PowerEdge R750')
|
||||
firmware_list = catalog.fetch_from_dell_support_api(model)
|
||||
|
||||
else:
|
||||
return {'success': False, 'count': 0, 'message': f'알 수 없는 방법: {method}'}
|
||||
|
||||
# DB에 저장
|
||||
count = catalog.save_to_database(firmware_list)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'count': count,
|
||||
'message': f'{count}개 펌웨어 정보 동기화 완료'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'count': 0,
|
||||
'message': f'오류: {str(e)}'
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import requests
|
||||
from xml.etree import ElementTree as ET
|
||||
from backend.models.firmware_version import FirmwareVersion, db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def get_manual_firmware_catalog(model="PowerEdge R750"):
|
||||
"""
|
||||
수동으로 작성한 펌웨어 카탈로그
|
||||
|
||||
Dell 공식 사이트에서 확인한 최신 버전 정보
|
||||
https://www.dell.com/support/
|
||||
|
||||
Dell Catalog.xml이 차단된 경우 이 데이터 사용
|
||||
"""
|
||||
# 2024년 11월 기준 최신 버전 (정기적으로 업데이트 필요)
|
||||
catalog = {
|
||||
"PowerEdge R750": [
|
||||
{
|
||||
'component_name': 'BIOS',
|
||||
'latest_version': '2.15.2',
|
||||
'release_date': '2024-03-15',
|
||||
'notes': 'PowerEdge R750 BIOS - 보안 업데이트 포함',
|
||||
'is_critical': False
|
||||
},
|
||||
{
|
||||
'component_name': 'iDRAC',
|
||||
'latest_version': '7.00.00.00',
|
||||
'release_date': '2024-02-20',
|
||||
'notes': 'iDRAC9 최신 펌웨어',
|
||||
'is_critical': True
|
||||
},
|
||||
{
|
||||
'component_name': 'PERC H755',
|
||||
'latest_version': '25.5.9.0001',
|
||||
'release_date': '2024-01-10',
|
||||
'notes': 'PERC H755 RAID 컨트롤러',
|
||||
'is_critical': False
|
||||
},
|
||||
{
|
||||
'component_name': 'CPLD',
|
||||
'latest_version': '1.0.6',
|
||||
'release_date': '2023-12-15',
|
||||
'notes': '시스템 보드 CPLD',
|
||||
'is_critical': False
|
||||
},
|
||||
],
|
||||
"PowerEdge R640": [
|
||||
{
|
||||
'component_name': 'BIOS',
|
||||
'latest_version': '2.19.2',
|
||||
'release_date': '2024-02-01',
|
||||
'notes': 'PowerEdge R640 BIOS',
|
||||
'is_critical': False
|
||||
},
|
||||
{
|
||||
'component_name': 'iDRAC',
|
||||
'latest_version': '7.00.00.00',
|
||||
'release_date': '2024-02-20',
|
||||
'notes': 'iDRAC9 최신 펌웨어',
|
||||
'is_critical': True
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return catalog.get(model, [])
|
||||
|
||||
|
||||
def sync_dell_catalog(model="PowerEdge R750"):
|
||||
"""
|
||||
Dell 펌웨어 정보 동기화
|
||||
|
||||
1차: Dell 공식 Catalog.xml 시도
|
||||
2차: 수동 카탈로그 사용 (Fallback)
|
||||
"""
|
||||
count = 0
|
||||
|
||||
# 1차 시도: Dell 공식 Catalog.xml
|
||||
try:
|
||||
url = "https://downloads.dell.com/catalog/Catalog.xml"
|
||||
print(f"[INFO] Dell Catalog 다운로드 시도... ({url})")
|
||||
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
root = ET.fromstring(response.content)
|
||||
|
||||
for pkg in root.findall(".//SoftwareComponent"):
|
||||
# 이 컴포넌트가 지정된 모델에 해당하는지 확인
|
||||
supported = [
|
||||
sys.text.strip()
|
||||
for sys in pkg.findall(".//SupportedSystems/Brand/Model/Display")
|
||||
if sys.text
|
||||
]
|
||||
if model not in supported:
|
||||
continue
|
||||
|
||||
name = pkg.findtext("Name")
|
||||
version = pkg.findtext("Version")
|
||||
release_date = pkg.findtext("ReleaseDate")
|
||||
path = pkg.findtext("path")
|
||||
vendor = "Dell"
|
||||
|
||||
if not name or not version:
|
||||
continue
|
||||
|
||||
# 중복 방지
|
||||
existing = FirmwareVersion.query.filter_by(
|
||||
component_name=name,
|
||||
latest_version=version,
|
||||
server_model=model
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
db.session.add(
|
||||
FirmwareVersion(
|
||||
component_name=name,
|
||||
latest_version=version,
|
||||
release_date=release_date,
|
||||
vendor=vendor,
|
||||
server_model=model,
|
||||
download_url=f"https://downloads.dell.com/{path}",
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
|
||||
db.session.commit()
|
||||
print(f"✓ {model} 관련 펌웨어 {count}개 동기화 완료 (Dell Catalog)")
|
||||
return count
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
print(f"[경고] Dell Catalog.xml 접근 불가 (404). 수동 카탈로그 사용...")
|
||||
else:
|
||||
print(f"[경고] Dell Catalog 다운로드 실패: {e}. 수동 카탈로그 사용...")
|
||||
except Exception as e:
|
||||
print(f"[경고] Dell Catalog 처리 중 오류: {e}. 수동 카탈로그 사용...")
|
||||
|
||||
# 2차 시도: 수동 카탈로그 사용
|
||||
try:
|
||||
print(f"[INFO] 수동 카탈로그에서 {model} 펌웨어 정보 로드 중...")
|
||||
manual_catalog = get_manual_firmware_catalog(model)
|
||||
|
||||
if not manual_catalog:
|
||||
print(f"[경고] {model}에 대한 수동 카탈로그 데이터가 없습니다")
|
||||
return 0
|
||||
|
||||
for fw in manual_catalog:
|
||||
# 중복 방지
|
||||
existing = FirmwareVersion.query.filter_by(
|
||||
component_name=fw['component_name'],
|
||||
latest_version=fw['latest_version'],
|
||||
server_model=model
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
db.session.add(
|
||||
FirmwareVersion(
|
||||
component_name=fw['component_name'],
|
||||
latest_version=fw['latest_version'],
|
||||
release_date=fw.get('release_date'),
|
||||
vendor='Dell',
|
||||
server_model=model,
|
||||
notes=fw.get('notes'),
|
||||
is_critical=fw.get('is_critical', False)
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
|
||||
db.session.commit()
|
||||
print(f"✓ {model} 관련 펌웨어 {count}개 동기화 완료 (수동 카탈로그)")
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
print(f"[오류] 수동 카탈로그 처리 실패: {e}")
|
||||
db.session.rollback()
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Dell Repository Manager (DRM) 연동 모듈
|
||||
backend/services/drm_catalog_sync.py
|
||||
|
||||
Dell Repository Manager에서 생성한 로컬 리포지토리 카탈로그를 파싱하여
|
||||
펌웨어 정보를 가져오는 기능
|
||||
"""
|
||||
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from backend.models.firmware_version import FirmwareVersion, db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class DRMCatalogSync:
|
||||
"""Dell Repository Manager 카탈로그 동기화"""
|
||||
|
||||
def __init__(self, drm_repository_path: str = None):
|
||||
"""
|
||||
Args:
|
||||
drm_repository_path: DRM 리포지토리 경로
|
||||
예: C:/Dell/Repository 또는 네트워크 경로
|
||||
"""
|
||||
self.repository_path = drm_repository_path
|
||||
self.catalog_file = None
|
||||
|
||||
if drm_repository_path:
|
||||
# DRM은 보통 catalog 폴더에 XML 파일 생성
|
||||
possible_catalogs = [
|
||||
os.path.join(drm_repository_path, 'catalog', 'Catalog.xml'),
|
||||
os.path.join(drm_repository_path, 'Catalog.xml'),
|
||||
os.path.join(drm_repository_path, 'catalog.xml'),
|
||||
]
|
||||
|
||||
for catalog_path in possible_catalogs:
|
||||
if os.path.exists(catalog_path):
|
||||
self.catalog_file = catalog_path
|
||||
print(f"[INFO] DRM 카탈로그 파일 발견: {catalog_path}")
|
||||
break
|
||||
|
||||
def sync_from_drm_repository(self, model: str = "PowerEdge R750") -> int:
|
||||
"""
|
||||
DRM 리포지토리에서 펌웨어 정보 동기화
|
||||
|
||||
Args:
|
||||
model: 서버 모델명
|
||||
|
||||
Returns:
|
||||
동기화된 펌웨어 개수
|
||||
"""
|
||||
if not self.catalog_file or not os.path.exists(self.catalog_file):
|
||||
print(f"[오류] DRM 카탈로그 파일을 찾을 수 없습니다: {self.repository_path}")
|
||||
return 0
|
||||
|
||||
try:
|
||||
print(f"[INFO] DRM 카탈로그 파싱 중: {self.catalog_file}")
|
||||
|
||||
# XML 파싱
|
||||
tree = ET.parse(self.catalog_file)
|
||||
root = tree.getroot()
|
||||
|
||||
count = 0
|
||||
|
||||
# DRM 카탈로그 구조 파싱
|
||||
for pkg in root.findall(".//SoftwareComponent"):
|
||||
# 모델 필터링
|
||||
supported_models = self._get_supported_models(pkg)
|
||||
|
||||
if model and model not in supported_models:
|
||||
continue
|
||||
|
||||
# 펌웨어 정보 추출
|
||||
firmware_info = self._extract_firmware_info(pkg, model)
|
||||
|
||||
if firmware_info:
|
||||
# DB에 저장
|
||||
if self._save_firmware_to_db(firmware_info):
|
||||
count += 1
|
||||
|
||||
print(f"✓ DRM에서 {model} 관련 펌웨어 {count}개 동기화 완료")
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
print(f"[오류] DRM 카탈로그 동기화 실패: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 0
|
||||
|
||||
def _get_supported_models(self, pkg_element) -> List[str]:
|
||||
"""패키지가 지원하는 서버 모델 목록 추출"""
|
||||
models = []
|
||||
|
||||
# Dell 카탈로그 XML 구조
|
||||
for display in pkg_element.findall(".//SupportedSystems/Brand/Model/Display"):
|
||||
if display.text:
|
||||
models.append(display.text.strip())
|
||||
|
||||
return models
|
||||
|
||||
def _extract_firmware_info(self, pkg_element, model: str) -> Optional[Dict]:
|
||||
"""패키지에서 펌웨어 정보 추출"""
|
||||
try:
|
||||
name = pkg_element.findtext("Name")
|
||||
version = pkg_element.findtext("vendorVersion") # DRM은 vendorVersion 사용
|
||||
if not version:
|
||||
version = pkg_element.findtext("dellVersion") # 또는 dellVersion
|
||||
|
||||
release_date = pkg_element.findtext("dateTime")
|
||||
path = pkg_element.findtext("path")
|
||||
category = pkg_element.findtext("Category")
|
||||
|
||||
# 중요도 판단
|
||||
importance = pkg_element.findtext("ImportanceDisplay")
|
||||
is_critical = importance and "Critical" in importance
|
||||
|
||||
# 파일 정보
|
||||
file_name = None
|
||||
file_size_mb = None
|
||||
|
||||
if path:
|
||||
file_name = os.path.basename(path)
|
||||
# 실제 파일이 있으면 크기 확인
|
||||
if self.repository_path:
|
||||
full_path = os.path.join(self.repository_path, path)
|
||||
if os.path.exists(full_path):
|
||||
file_size_mb = os.path.getsize(full_path) // (1024 * 1024)
|
||||
|
||||
if not name or not version:
|
||||
return None
|
||||
|
||||
return {
|
||||
'component_name': name,
|
||||
'latest_version': version,
|
||||
'server_model': model,
|
||||
'vendor': 'Dell',
|
||||
'release_date': self._parse_date(release_date),
|
||||
'download_url': f"https://downloads.dell.com/{path}" if path else None,
|
||||
'file_name': file_name,
|
||||
'file_size_mb': file_size_mb,
|
||||
'component_type': category,
|
||||
'is_critical': is_critical,
|
||||
'notes': f"DRM에서 동기화 - {category}" if category else "DRM에서 동기화"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[경고] 펌웨어 정보 추출 실패: {str(e)}")
|
||||
return None
|
||||
|
||||
def _parse_date(self, date_str: str) -> Optional[str]:
|
||||
"""날짜 문자열 파싱"""
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
# DRM 날짜 형식: 2024-03-15T10:30:00
|
||||
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
return dt.strftime('%Y-%m-%d')
|
||||
except:
|
||||
return date_str[:10] if len(date_str) >= 10 else None
|
||||
|
||||
def _save_firmware_to_db(self, firmware_info: Dict) -> bool:
|
||||
"""펌웨어 정보를 DB에 저장"""
|
||||
try:
|
||||
# 중복 확인
|
||||
existing = FirmwareVersion.query.filter_by(
|
||||
component_name=firmware_info['component_name'],
|
||||
latest_version=firmware_info['latest_version'],
|
||||
server_model=firmware_info['server_model']
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# 이미 존재하면 업데이트
|
||||
for key, value in firmware_info.items():
|
||||
if hasattr(existing, key) and value is not None:
|
||||
setattr(existing, key, value)
|
||||
db.session.commit()
|
||||
return False # 새로 추가된 것은 아님
|
||||
else:
|
||||
# 새로 추가
|
||||
version = FirmwareVersion(**firmware_info)
|
||||
db.session.add(version)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[오류] DB 저장 실패: {str(e)}")
|
||||
db.session.rollback()
|
||||
return False
|
||||
|
||||
def list_available_models(self) -> List[str]:
|
||||
"""DRM 리포지토리에서 사용 가능한 모델 목록 조회"""
|
||||
if not self.catalog_file or not os.path.exists(self.catalog_file):
|
||||
return []
|
||||
|
||||
try:
|
||||
tree = ET.parse(self.catalog_file)
|
||||
root = tree.getroot()
|
||||
|
||||
models = set()
|
||||
|
||||
for display in root.findall(".//SupportedSystems/Brand/Model/Display"):
|
||||
if display.text:
|
||||
models.add(display.text.strip())
|
||||
|
||||
return sorted(list(models))
|
||||
|
||||
except Exception as e:
|
||||
print(f"[오류] 모델 목록 조회 실패: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_repository_info(self) -> Dict:
|
||||
"""DRM 리포지토리 정보 조회"""
|
||||
if not self.catalog_file or not os.path.exists(self.catalog_file):
|
||||
return {
|
||||
'exists': False,
|
||||
'path': self.repository_path,
|
||||
'catalog_file': None
|
||||
}
|
||||
|
||||
try:
|
||||
tree = ET.parse(self.catalog_file)
|
||||
root = tree.getroot()
|
||||
|
||||
# 카탈로그 메타데이터
|
||||
base_location = root.findtext(".//BaseLocation")
|
||||
release_id = root.findtext(".//ReleaseID")
|
||||
|
||||
# 패키지 수 계산
|
||||
total_packages = len(root.findall(".//SoftwareComponent"))
|
||||
|
||||
return {
|
||||
'exists': True,
|
||||
'path': self.repository_path,
|
||||
'catalog_file': self.catalog_file,
|
||||
'base_location': base_location,
|
||||
'release_id': release_id,
|
||||
'total_packages': total_packages,
|
||||
'available_models': self.list_available_models()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'exists': True,
|
||||
'path': self.repository_path,
|
||||
'catalog_file': self.catalog_file,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
# ========================================
|
||||
# 편의 함수
|
||||
# ========================================
|
||||
|
||||
def sync_from_drm(repository_path: str, model: str = "PowerEdge R750") -> Dict:
|
||||
"""
|
||||
DRM 리포지토리에서 펌웨어 동기화 (간편 함수)
|
||||
|
||||
Args:
|
||||
repository_path: DRM 리포지토리 경로
|
||||
model: 서버 모델명
|
||||
|
||||
Returns:
|
||||
{'success': bool, 'count': int, 'message': str}
|
||||
"""
|
||||
try:
|
||||
drm = DRMCatalogSync(repository_path)
|
||||
|
||||
# 리포지토리 확인
|
||||
info = drm.get_repository_info()
|
||||
if not info['exists']:
|
||||
return {
|
||||
'success': False,
|
||||
'count': 0,
|
||||
'message': f'DRM 리포지토리를 찾을 수 없습니다: {repository_path}'
|
||||
}
|
||||
|
||||
# 동기화
|
||||
count = drm.sync_from_drm_repository(model)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'count': count,
|
||||
'message': f'{model} 펌웨어 {count}개 동기화 완료',
|
||||
'repository_info': info
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'count': 0,
|
||||
'message': f'DRM 동기화 실패: {str(e)}'
|
||||
}
|
||||
|
||||
|
||||
def check_drm_repository(repository_path: str) -> Dict:
|
||||
"""
|
||||
DRM 리포지토리 상태 확인
|
||||
|
||||
Args:
|
||||
repository_path: DRM 리포지토리 경로
|
||||
|
||||
Returns:
|
||||
리포지토리 정보
|
||||
"""
|
||||
drm = DRMCatalogSync(repository_path)
|
||||
return drm.get_repository_info()
|
||||
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
iDRAC Job Monitoring Service (Redfish 버전)
|
||||
기존 Flask 앱의 backend/services/ 디렉토리에 추가하세요.
|
||||
기존 idrac_jobs.py를 이 파일로 교체하거나 redfish_jobs.py로 저장하세요.
|
||||
"""
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
import ipaddress
|
||||
import time
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
from .redfish_client import RedfishClient, AuthenticationError, NotSupportedError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 설정 (환경변수 또는 기본값)
|
||||
# ────────────────────────────────────────────────────────────
|
||||
IDRAC_USER = os.getenv("IDRAC_USER", "root")
|
||||
IDRAC_PASS = os.getenv("IDRAC_PASS", "calvin")
|
||||
MAX_WORKERS = int(os.getenv("MAX_WORKERS", "32"))
|
||||
REDFISH_TIMEOUT = int(os.getenv("REDFISH_TIMEOUT", "15"))
|
||||
VERIFY_SSL = os.getenv("VERIFY_SSL", "False").lower() == "true"
|
||||
IP_LIST_PATH = os.getenv("IDRAC_IP_LIST", "data/server_list/idrac_ip_list.txt")
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# IP 유효성 검증
|
||||
# ────────────────────────────────────────────────────────────
|
||||
def validate_ip(ip: str) -> bool:
|
||||
"""IP 주소 유효성 검증"""
|
||||
try:
|
||||
ipaddress.ip_address(ip.strip())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def parse_ip_list(text: str) -> List[str]:
|
||||
"""텍스트에서 IP 목록 파싱"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
raw = text.replace(",", "\n").replace(";", "\n")
|
||||
ips = []
|
||||
seen = set()
|
||||
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
for part in line.split():
|
||||
part = part.strip()
|
||||
if not part or part.startswith("#"):
|
||||
continue
|
||||
|
||||
if validate_ip(part) and part not in seen:
|
||||
seen.add(part)
|
||||
ips.append(part)
|
||||
elif not validate_ip(part):
|
||||
logger.warning(f"Invalid IP address: {part}")
|
||||
|
||||
return ips
|
||||
|
||||
|
||||
def load_ip_list(path: str = IP_LIST_PATH) -> List[str]:
|
||||
"""파일에서 IP 목록 로드"""
|
||||
try:
|
||||
file_path = Path(path)
|
||||
if not file_path.exists():
|
||||
logger.warning(f"IP list file not found: {path}")
|
||||
return []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
ips = parse_ip_list(content)
|
||||
logger.info(f"Loaded {len(ips)} IPs from {path}")
|
||||
return ips
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load IP list from {path}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Job 상태 판별
|
||||
# ────────────────────────────────────────────────────────────
|
||||
ACTIVE_KEYWORDS = (
|
||||
"running", "scheduled", "progress", "starting",
|
||||
"queued", "pending", "preparing", "applying"
|
||||
)
|
||||
|
||||
DONE_KEYWORDS = (
|
||||
"completed", "success", "succeeded",
|
||||
"failed", "error", "aborted",
|
||||
"canceled", "cancelled"
|
||||
)
|
||||
|
||||
|
||||
def is_active_status(status: Optional[str], message: Optional[str] = None) -> bool:
|
||||
"""Job이 활성 상태인지 확인"""
|
||||
s = (status or "").strip().lower()
|
||||
m = (message or "").strip().lower()
|
||||
return any(k in s for k in ACTIVE_KEYWORDS) or any(k in m for k in ACTIVE_KEYWORDS)
|
||||
|
||||
|
||||
def is_done_status(status: Optional[str]) -> bool:
|
||||
"""Job이 완료 상태인지 확인"""
|
||||
s = (status or "").strip().lower()
|
||||
return any(k in s for k in DONE_KEYWORDS)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 날짜/시간 파싱
|
||||
# ────────────────────────────────────────────────────────────
|
||||
def parse_iso_datetime(dt_str: Optional[str]) -> Optional[float]:
|
||||
"""ISO 8601 날짜 문자열을 timestamp로 변환"""
|
||||
if not dt_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
||||
return dt.timestamp()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse datetime '{dt_str}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
def iso_now() -> str:
|
||||
"""현재 시간을 ISO 8601 포맷으로 반환"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# LRU 캐시
|
||||
# ────────────────────────────────────────────────────────────
|
||||
class LRUJobCache:
|
||||
"""Job 캐시 (LRU 방식)"""
|
||||
|
||||
def __init__(self, max_size: int = 10000):
|
||||
self.cache: OrderedDict[Tuple[str, str], Dict[str, Any]] = OrderedDict()
|
||||
self.max_size = max_size
|
||||
self.last_gc = time.time()
|
||||
|
||||
def _make_key(self, ip: str, job: Dict[str, Any]) -> Tuple[str, str]:
|
||||
"""캐시 키 생성"""
|
||||
jid = (job.get("JID") or "").strip()
|
||||
if jid:
|
||||
return (ip, jid)
|
||||
name = (job.get("Name") or "").strip()
|
||||
return (ip, f"NOJID::{name}")
|
||||
|
||||
def get(self, key: Tuple[str, str]) -> Optional[Dict[str, Any]]:
|
||||
"""캐시에서 조회"""
|
||||
if key in self.cache:
|
||||
self.cache.move_to_end(key)
|
||||
return self.cache[key]
|
||||
return None
|
||||
|
||||
def set(self, key: Tuple[str, str], value: Dict[str, Any]):
|
||||
"""캐시에 저장"""
|
||||
if key in self.cache:
|
||||
self.cache.move_to_end(key)
|
||||
|
||||
self.cache[key] = value
|
||||
|
||||
if len(self.cache) > self.max_size:
|
||||
self.cache.popitem(last=False)
|
||||
|
||||
def keys(self) -> List[Tuple[str, str]]:
|
||||
"""모든 키 반환"""
|
||||
return list(self.cache.keys())
|
||||
|
||||
def pop(self, key: Tuple[str, str], default=None):
|
||||
"""캐시에서 제거"""
|
||||
return self.cache.pop(key, default)
|
||||
|
||||
def clear_for_ips(self, current_ips: set):
|
||||
"""현재 IP 목록에 없는 항목 제거"""
|
||||
removed = 0
|
||||
for key in list(self.cache.keys()):
|
||||
if key[0] not in current_ips:
|
||||
self.cache.pop(key)
|
||||
removed += 1
|
||||
|
||||
if removed > 0:
|
||||
logger.info(f"Cleared {removed} cache entries for removed IPs")
|
||||
|
||||
def gc(self, max_age_seconds: float):
|
||||
"""오래된 캐시 항목 제거"""
|
||||
now = time.time()
|
||||
cutoff = now - max_age_seconds
|
||||
removed = 0
|
||||
|
||||
for key in list(self.cache.keys()):
|
||||
entry = self.cache[key]
|
||||
if entry.get("last_seen", 0) < cutoff:
|
||||
self.cache.pop(key)
|
||||
removed += 1
|
||||
|
||||
if removed > 0:
|
||||
logger.info(f"Cache GC: removed {removed} entries")
|
||||
|
||||
self.last_gc = now
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Job 스캐너
|
||||
# ────────────────────────────────────────────────────────────
|
||||
def scan_single_ip(ip: str) -> Dict[str, Any]:
|
||||
"""
|
||||
단일 IP에서 Job 조회
|
||||
|
||||
Returns:
|
||||
{
|
||||
"ip": str,
|
||||
"ok": bool,
|
||||
"error": str (실패 시),
|
||||
"jobs": List[Dict]
|
||||
}
|
||||
"""
|
||||
if not validate_ip(ip):
|
||||
return {
|
||||
"ip": ip,
|
||||
"ok": False,
|
||||
"error": "Invalid IP address",
|
||||
"jobs": []
|
||||
}
|
||||
|
||||
try:
|
||||
with RedfishClient(ip, IDRAC_USER, IDRAC_PASS, REDFISH_TIMEOUT, VERIFY_SSL) as client:
|
||||
jobs = client.get_jobs()
|
||||
return {
|
||||
"ip": ip,
|
||||
"ok": True,
|
||||
"jobs": jobs
|
||||
}
|
||||
except AuthenticationError:
|
||||
return {
|
||||
"ip": ip,
|
||||
"ok": False,
|
||||
"error": "Authentication failed",
|
||||
"jobs": []
|
||||
}
|
||||
except NotSupportedError:
|
||||
return {
|
||||
"ip": ip,
|
||||
"ok": False,
|
||||
"error": "Redfish API not supported (old iDRAC?)",
|
||||
"jobs": []
|
||||
}
|
||||
except TimeoutError as e:
|
||||
return {
|
||||
"ip": ip,
|
||||
"ok": False,
|
||||
"error": f"Timeout: {str(e)}",
|
||||
"jobs": []
|
||||
}
|
||||
except ConnectionError as e:
|
||||
return {
|
||||
"ip": ip,
|
||||
"ok": False,
|
||||
"error": f"Connection failed: {str(e)}",
|
||||
"jobs": []
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error for {ip}")
|
||||
return {
|
||||
"ip": ip,
|
||||
"ok": False,
|
||||
"error": f"Unexpected error: {str(e)[:100]}",
|
||||
"jobs": []
|
||||
}
|
||||
|
||||
|
||||
def scan_all(ips: List[str], method: str = "redfish", max_workers: int = MAX_WORKERS) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
여러 IP를 병렬로 스캔
|
||||
|
||||
Args:
|
||||
ips: IP 목록
|
||||
method: "redfish" (racadm은 하위 호환용)
|
||||
max_workers: 병렬 워커 수
|
||||
|
||||
Returns:
|
||||
IP별 결과 리스트 (정렬됨)
|
||||
"""
|
||||
if not ips:
|
||||
return []
|
||||
|
||||
logger.info(f"Scanning {len(ips)} IPs with {max_workers} workers (method: {method})")
|
||||
start_time = time.time()
|
||||
|
||||
results = []
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(scan_single_ip, ip): ip
|
||||
for ip in ips
|
||||
}
|
||||
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"Scan completed in {elapsed:.2f}s")
|
||||
|
||||
return sorted(results, key=lambda x: x["ip"])
|
||||
@@ -0,0 +1,995 @@
|
||||
"""
|
||||
Dell iDRAC REDFISH API 클라이언트
|
||||
Dell 서버 펌웨어 조회 최적화 버전
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
# SSL 경고 비활성화
|
||||
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
|
||||
|
||||
|
||||
class DellRedfishClient:
|
||||
def __init__(self, idrac_ip, username, password):
|
||||
"""
|
||||
Dell iDRAC REDFISH 클라이언트 초기화
|
||||
|
||||
Args:
|
||||
idrac_ip: iDRAC IP 주소
|
||||
username: iDRAC 사용자명
|
||||
password: iDRAC 비밀번호
|
||||
"""
|
||||
self.idrac_ip = idrac_ip
|
||||
self.base_url = f"https://{idrac_ip}"
|
||||
self.auth = HTTPBasicAuth(username, password)
|
||||
self.headers = {'Content-Type': 'application/json'}
|
||||
self.session = requests.Session()
|
||||
self.session.verify = False
|
||||
self.session.auth = self.auth
|
||||
|
||||
def check_connection(self):
|
||||
"""iDRAC 연결 확인"""
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1"
|
||||
response = self.session.get(url, timeout=10)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
print(f"연결 오류: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_firmware_inventory(self):
|
||||
"""
|
||||
현재 설치된 펌웨어 목록 조회 (Dell 최적화)
|
||||
주요 컴포넌트만 필터링하여 반환
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1/UpdateService/FirmwareInventory"
|
||||
response = self.session.get(url, timeout=30)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"펌웨어 목록 조회 실패: {response.status_code}")
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
inventory = []
|
||||
|
||||
# 주요 컴포넌트 타입 (Dell 서버)
|
||||
important_components = [
|
||||
'BIOS', 'iDRAC', 'CPLD', 'Diagnostics',
|
||||
'Driver', 'Firmware', 'USC', 'NIC',
|
||||
'RAID', 'PERC', 'Storage', 'Backplane',
|
||||
'HBA', 'Network', 'Intel', 'Broadcom',
|
||||
'Mellanox', 'Emulex', 'QLogic'
|
||||
]
|
||||
|
||||
# 로그 간소화: 전체 펌웨어 항목 수 출력 제거
|
||||
|
||||
# 각 펌웨어 항목 조회
|
||||
for idx, member in enumerate(data.get('Members', [])):
|
||||
try:
|
||||
fw_url = f"{self.base_url}{member.get('@odata.id', '')}"
|
||||
fw_response = self.session.get(fw_url, timeout=10)
|
||||
|
||||
if fw_response.status_code == 200:
|
||||
fw_data = fw_response.json()
|
||||
|
||||
# 이름과 버전 추출 (여러 필드 시도)
|
||||
name = (fw_data.get('Name') or
|
||||
fw_data.get('SoftwareId') or
|
||||
fw_data.get('Id') or
|
||||
'Unknown')
|
||||
|
||||
version = (fw_data.get('Version') or
|
||||
fw_data.get('VersionString') or
|
||||
'Unknown')
|
||||
|
||||
# 컴포넌트 타입 확인
|
||||
component_type = fw_data.get('ComponentType', '')
|
||||
|
||||
# 중요 컴포넌트만 필터링
|
||||
is_important = any(comp.lower() in name.lower()
|
||||
for comp in important_components)
|
||||
|
||||
if is_important or component_type:
|
||||
item = {
|
||||
'Name': name,
|
||||
'Version': version,
|
||||
'ComponentType': component_type,
|
||||
'Updateable': fw_data.get('Updateable', False),
|
||||
'Status': fw_data.get('Status', {}).get('Health', 'OK'),
|
||||
'Id': fw_data.get('Id', ''),
|
||||
'Description': fw_data.get('Description', ''),
|
||||
'ReleaseDate': fw_data.get('ReleaseDate', '')
|
||||
}
|
||||
|
||||
inventory.append(item)
|
||||
|
||||
# 진행 상황 출력
|
||||
if (idx + 1) % 10 == 0:
|
||||
pass # 로그 간소화: 진행률 출력 제거
|
||||
|
||||
except Exception as e:
|
||||
print(f"펌웨어 항목 조회 오류 ({idx}): {str(e)}")
|
||||
continue
|
||||
|
||||
# 이름순 정렬
|
||||
inventory.sort(key=lambda x: x['Name'])
|
||||
|
||||
# 로그 간소화: 중요 펌웨어 항목 수 출력 제거
|
||||
|
||||
# 주요 컴포넌트 요약
|
||||
bios = next((x for x in inventory if 'BIOS' in x['Name']), None)
|
||||
idrac = next((x for x in inventory if 'iDRAC' in x['Name']), None)
|
||||
|
||||
if bios:
|
||||
pass # 로그 간소화: BIOS 버전 출력 제거
|
||||
if idrac:
|
||||
pass # 로그 간소화: iDRAC 버전 출력 제거
|
||||
|
||||
return inventory
|
||||
|
||||
except Exception as e:
|
||||
print(f"펌웨어 목록 조회 오류: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def get_firmware_summary(self):
|
||||
"""
|
||||
주요 펌웨어 버전만 간단히 조회
|
||||
BIOS, iDRAC, PERC 등 핵심 컴포넌트만
|
||||
"""
|
||||
try:
|
||||
inventory = self.get_firmware_inventory()
|
||||
|
||||
summary = {
|
||||
'BIOS': 'N/A',
|
||||
'iDRAC': 'N/A',
|
||||
'PERC': 'N/A',
|
||||
'NIC': 'N/A',
|
||||
'CPLD': 'N/A'
|
||||
}
|
||||
|
||||
for item in inventory:
|
||||
name = item['Name']
|
||||
version = item['Version']
|
||||
|
||||
if 'BIOS' in name:
|
||||
summary['BIOS'] = version
|
||||
elif 'iDRAC' in name or 'Integrated Dell Remote Access' in name:
|
||||
summary['iDRAC'] = version
|
||||
elif 'PERC' in name or 'RAID' in name:
|
||||
if summary['PERC'] == 'N/A':
|
||||
summary['PERC'] = version
|
||||
elif 'NIC' in name or 'Network' in name:
|
||||
if summary['NIC'] == 'N/A':
|
||||
summary['NIC'] = version
|
||||
elif 'CPLD' in name:
|
||||
summary['CPLD'] = version
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
print(f"펌웨어 요약 조회 오류: {str(e)}")
|
||||
return {}
|
||||
|
||||
def upload_firmware_staged(self, dup_file_path):
|
||||
"""
|
||||
DUP 파일을 iDRAC에 업로드 (스테이징만, 재부팅 시 적용)
|
||||
|
||||
Args:
|
||||
dup_file_path: 업로드할 DUP 파일 경로
|
||||
|
||||
Returns:
|
||||
dict: {'success': bool, 'job_id': str, 'message': str}
|
||||
"""
|
||||
import os
|
||||
if not os.path.exists(dup_file_path):
|
||||
return {
|
||||
'success': False,
|
||||
'job_id': None,
|
||||
'message': f'파일을 찾을 수 없습니다: {dup_file_path}'
|
||||
}
|
||||
|
||||
file_name = os.path.basename(dup_file_path)
|
||||
file_size = os.path.getsize(dup_file_path)
|
||||
|
||||
print(f"파일 업로드: {file_name} ({file_size / (1024*1024):.2f} MB)")
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1/UpdateService/MultipartUpload"
|
||||
|
||||
# Multipart 업로드 준비
|
||||
with open(dup_file_path, 'rb') as f:
|
||||
files = {
|
||||
'file': (file_name, f, 'application/octet-stream')
|
||||
}
|
||||
|
||||
# targets=INSTALLED 파라미터로 스테이징 모드 설정
|
||||
data = {
|
||||
'@Redfish.OperationApplyTime': 'OnReset',
|
||||
'Targets': []
|
||||
}
|
||||
|
||||
multipart_data = {
|
||||
'UpdateParameters': (None, json.dumps(data), 'application/json')
|
||||
}
|
||||
multipart_data.update(files)
|
||||
|
||||
print("업로드 시작...")
|
||||
response = self.session.post(
|
||||
url,
|
||||
files=multipart_data,
|
||||
auth=self.auth,
|
||||
verify=False,
|
||||
timeout=600 # 10분 타임아웃
|
||||
)
|
||||
|
||||
print(f"응답 코드: {response.status_code}")
|
||||
|
||||
if response.status_code in [200, 201, 202]:
|
||||
response_data = response.json()
|
||||
|
||||
# Task 또는 Job ID 추출
|
||||
task_id = None
|
||||
if '@odata.id' in response_data:
|
||||
task_id = response_data['@odata.id'].split('/')[-1]
|
||||
elif 'Id' in response_data:
|
||||
task_id = response_data['Id']
|
||||
|
||||
# Location 헤더에서 Job ID 추출
|
||||
location = response.headers.get('Location', '')
|
||||
if location and not task_id:
|
||||
task_id = location.split('/')[-1]
|
||||
|
||||
print(f"업로드 완료! Task/Job ID: {task_id}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'job_id': task_id or 'STAGED',
|
||||
'message': '업로드 완료 (재부팅 시 적용)'
|
||||
}
|
||||
else:
|
||||
error_msg = response.text
|
||||
print(f"업로드 실패: {error_msg}")
|
||||
return {
|
||||
'success': False,
|
||||
'job_id': None,
|
||||
'message': f'업로드 실패 (코드: {response.status_code})'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"업로드 오류: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
'success': False,
|
||||
'job_id': None,
|
||||
'message': f'업로드 오류: {str(e)}'
|
||||
}
|
||||
|
||||
def get_job_queue(self):
|
||||
"""Job Queue 조회"""
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/Jobs"
|
||||
response = self.session.get(url, timeout=30)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
jobs = []
|
||||
|
||||
for member in data.get('Members', [])[:20]: # 최근 20개
|
||||
job_url = f"{self.base_url}{member['@odata.id']}"
|
||||
job_response = self.session.get(job_url, timeout=10)
|
||||
|
||||
if job_response.status_code == 200:
|
||||
job_data = job_response.json()
|
||||
jobs.append({
|
||||
'Id': job_data.get('Id', ''),
|
||||
'Name': job_data.get('Name', ''),
|
||||
'JobState': job_data.get('JobState', 'Unknown'),
|
||||
'PercentComplete': job_data.get('PercentComplete', 0),
|
||||
'Message': job_data.get('Message', ''),
|
||||
'StartTime': job_data.get('StartTime', ''),
|
||||
'EndTime': job_data.get('EndTime', '')
|
||||
})
|
||||
|
||||
return jobs
|
||||
else:
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"Job Queue 조회 오류: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_job_status(self, job_id):
|
||||
"""특정 Job 상태 조회"""
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/Jobs/{job_id}"
|
||||
response = self.session.get(url, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
'Id': data.get('Id', ''),
|
||||
'Name': data.get('Name', ''),
|
||||
'JobState': data.get('JobState', 'Unknown'),
|
||||
'PercentComplete': data.get('PercentComplete', 0),
|
||||
'Message': data.get('Message', ''),
|
||||
'StartTime': data.get('StartTime', ''),
|
||||
'EndTime': data.get('EndTime', '')
|
||||
}
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Job 상태 조회 오류: {str(e)}")
|
||||
return None
|
||||
|
||||
def delete_job(self, job_id):
|
||||
"""Job 삭제"""
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/Jobs/{job_id}"
|
||||
response = self.session.delete(url, timeout=10)
|
||||
return response.status_code in [200, 204]
|
||||
except Exception as e:
|
||||
print(f"Job 삭제 오류: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_power_status(self):
|
||||
"""서버 전원 상태 조회"""
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1"
|
||||
response = self.session.get(url, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
'PowerState': data.get('PowerState', 'Unknown'),
|
||||
'Status': data.get('Status', {}).get('Health', 'Unknown')
|
||||
}
|
||||
else:
|
||||
return {'PowerState': 'Unknown', 'Status': 'Unknown'}
|
||||
|
||||
except Exception as e:
|
||||
print(f"전원 상태 조회 오류: {str(e)}")
|
||||
return {'PowerState': 'Unknown', 'Status': 'Unknown'}
|
||||
|
||||
def reboot_server(self, reset_type='GracefulRestart'):
|
||||
"""
|
||||
서버 재부팅
|
||||
|
||||
Args:
|
||||
reset_type: GracefulRestart, ForceRestart, GracefulShutdown, ForceOff, On
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset"
|
||||
payload = {'ResetType': reset_type}
|
||||
|
||||
response = self.session.post(
|
||||
url,
|
||||
data=json.dumps(payload),
|
||||
headers=self.headers,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
return response.status_code in [200, 202, 204]
|
||||
|
||||
except Exception as e:
|
||||
print(f"재부팅 오류: {str(e)}")
|
||||
return False
|
||||
|
||||
def power_on_server(self):
|
||||
"""서버 전원 켜기"""
|
||||
return self.reboot_server('On')
|
||||
|
||||
def power_off_server(self, shutdown_type='GracefulShutdown'):
|
||||
"""
|
||||
서버 전원 끄기
|
||||
|
||||
Args:
|
||||
shutdown_type: GracefulShutdown 또는 ForceOff
|
||||
"""
|
||||
return self.reboot_server(shutdown_type)
|
||||
|
||||
def get_bios_attributes_all(self):
|
||||
"""
|
||||
설정된 모든 BIOS 속성 조회
|
||||
|
||||
Returns:
|
||||
dict: {AttributeName: Value, ...}
|
||||
"""
|
||||
try:
|
||||
# 1. BIOS 리소스 조회
|
||||
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1/Bios"
|
||||
response = self.session.get(url, timeout=15)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Attributes가 바로 있는 경우 (표준)
|
||||
if 'Attributes' in data:
|
||||
return data['Attributes']
|
||||
|
||||
# Future: Attributes가 별도 링크인 경우 처리 필요할 수 있음
|
||||
return {}
|
||||
else:
|
||||
print(f"BIOS 속성 조회 실패: {response.status_code}")
|
||||
return {}
|
||||
|
||||
except Exception as e:
|
||||
print(f"BIOS 속성 조회 오류: {str(e)}")
|
||||
return {}
|
||||
|
||||
def get_bios_attribute_registry(self):
|
||||
"""
|
||||
BIOS Attribute Registry를 조회하여 속성 이름(AttributeName)과
|
||||
사용자 친화적인 이름(DisplayName)의 매핑 정보를 반환합니다.
|
||||
|
||||
Returns:
|
||||
dict: {AttributeName: DisplayName, ...}
|
||||
"""
|
||||
try:
|
||||
# 1. BIOS 리소스에서 AttributeRegistry 정보 확인
|
||||
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1/Bios"
|
||||
response = self.session.get(url, timeout=15)
|
||||
|
||||
if response.status_code != 200:
|
||||
print("BIOS 리소스 조회 실패")
|
||||
return {}
|
||||
|
||||
data = response.json()
|
||||
registry_name = data.get('AttributeRegistry')
|
||||
|
||||
if not registry_name:
|
||||
print("BIOS Attribute Registry 정보를 찾을 수 없음. 기본값으로 시도합니다.")
|
||||
registry_name = "BiosAttributeRegistry"
|
||||
|
||||
print(f"DEBUG: Looking for BIOS Registry: {registry_name}")
|
||||
|
||||
# 2. Registries 컬렉션에서 해당 레지스트리 찾기
|
||||
reg_url = f"{self.base_url}/redfish/v1/Registries"
|
||||
reg_res = self.session.get(reg_url, timeout=10)
|
||||
|
||||
target_location = None
|
||||
|
||||
if reg_res.status_code == 200:
|
||||
reg_data = reg_res.json()
|
||||
|
||||
# 검색할 이름 후보군 (버전 제거 등)
|
||||
search_candidates = [registry_name]
|
||||
if '.' in registry_name:
|
||||
search_candidates.append(registry_name.split('.')[0])
|
||||
|
||||
# 'BiosAttributeRegistry'가 없다면 추가 (하드코딩 폴백)
|
||||
if 'BiosAttributeRegistry' not in search_candidates:
|
||||
search_candidates.append('BiosAttributeRegistry')
|
||||
|
||||
print(f"DEBUG: Registry Candidates: {search_candidates}")
|
||||
|
||||
for member in reg_data.get('Members', []):
|
||||
m_url = member.get('@odata.id', '')
|
||||
|
||||
# URL 끝부분(ID) 추출
|
||||
# /redfish/v1/Registries/BiosAttributeRegistry
|
||||
m_id = m_url.rstrip('/').split('/')[-1]
|
||||
|
||||
match_found = False
|
||||
for candidate in search_candidates:
|
||||
if candidate.lower() in m_id.lower():
|
||||
match_found = True
|
||||
break
|
||||
|
||||
if match_found:
|
||||
print(f"DEBUG: Found Registry Match: {m_url}")
|
||||
# 레지스트리 상세 조회
|
||||
detail_url = f"{self.base_url}{m_url}"
|
||||
detail_res = self.session.get(detail_url, timeout=10)
|
||||
if detail_res.status_code == 200:
|
||||
detail_data = detail_res.json()
|
||||
# Location 정보 찾기
|
||||
locations = detail_data.get('Location', [])
|
||||
for loc in locations:
|
||||
if loc.get('Language', '').startswith('en'):
|
||||
target_location = loc.get('Uri')
|
||||
break
|
||||
if not target_location and locations:
|
||||
target_location = locations[0].get('Uri')
|
||||
|
||||
if target_location:
|
||||
break
|
||||
|
||||
# 3. Registry 파일(JSON) 다운로드 및 파싱
|
||||
if target_location:
|
||||
# URI가 전체 경로가 아닐 수 있음
|
||||
if not target_location.startswith('http'):
|
||||
final_url = f"{self.base_url}{target_location}"
|
||||
else:
|
||||
final_url = target_location
|
||||
|
||||
print(f"Downloading BIOS Registry from: {final_url}")
|
||||
file_res = self.session.get(final_url, timeout=30)
|
||||
|
||||
if file_res.status_code == 200:
|
||||
reg_json = file_res.json()
|
||||
mapping = {}
|
||||
|
||||
# RegistryEntries 파싱
|
||||
# 구조: { "RegistryEntries": { "Attributes": [ ... ] } } 또는 바로 Attributes
|
||||
attributes = []
|
||||
if 'RegistryEntries' in reg_json:
|
||||
if 'Attributes' in reg_json['RegistryEntries']:
|
||||
attributes = reg_json['RegistryEntries']['Attributes']
|
||||
else:
|
||||
# 썸네일 등 다른 구조일 수 있음, 여기서는 일반적 구조 따름
|
||||
pass
|
||||
elif 'Attributes' in reg_json:
|
||||
attributes = reg_json['Attributes']
|
||||
|
||||
for entry in attributes:
|
||||
attr_name = entry.get('AttributeName')
|
||||
display_name = entry.get('DisplayName')
|
||||
if attr_name and display_name:
|
||||
mapping[attr_name] = display_name
|
||||
|
||||
print(f"BIOS Registry 파싱 완료: {len(mapping)}개 항목")
|
||||
return mapping
|
||||
|
||||
print(f"BIOS Registry 위치를 찾을 수 없음 (TargetLocation: {target_location})")
|
||||
return {}
|
||||
|
||||
except Exception as e:
|
||||
print(f"BIOS Attribute Registry 조회 오류: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {}
|
||||
|
||||
|
||||
def _flatten_dict(self, d, parent_key='', sep='.'):
|
||||
"""중첩된 딕셔너리를 평탄화 (Nested JSON -> Flat JSON)"""
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
if isinstance(v, dict):
|
||||
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
|
||||
elif isinstance(v, list):
|
||||
# 리스트인 경우 인덱스를 붙여서 평탄화
|
||||
for i, item in enumerate(v):
|
||||
if isinstance(item, dict):
|
||||
items.extend(self._flatten_dict(item, f"{new_key}{sep}{i}", sep=sep).items())
|
||||
else:
|
||||
items.append((f"{new_key}{sep}{i}", item))
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
return dict(items)
|
||||
|
||||
def get_idrac_attributes_all(self):
|
||||
"""
|
||||
설정된 모든 iDRAC 속성 조회 (Attributes + NetworkProtocol + AccountService)
|
||||
|
||||
Returns:
|
||||
dict: {AttributeName: Value, ...} (Flattened)
|
||||
"""
|
||||
all_attributes = {}
|
||||
|
||||
try:
|
||||
# 1. 기본 Attributes 조회
|
||||
url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/Attributes"
|
||||
response = self.session.get(url, timeout=15)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
attrs = data.get('Attributes', data)
|
||||
# @odata 필드 제거
|
||||
attrs = {k: v for k, v in attrs.items() if not k.startswith('@odata')}
|
||||
all_attributes.update(attrs)
|
||||
|
||||
# 2. Network Protocol 조회 (NTP, SNMP, IPMI, SSH 등)
|
||||
net_url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/NetworkProtocol"
|
||||
net_res = self.session.get(net_url, timeout=10)
|
||||
if net_res.status_code == 200:
|
||||
net_data = net_res.json()
|
||||
# 불필요한 필드 제거
|
||||
exclude_keys = ['@odata.context', '@odata.id', '@odata.type', 'Name', 'Description', 'Id', 'Status']
|
||||
filtered_net = {k: v for k, v in net_data.items() if k not in exclude_keys}
|
||||
|
||||
# 평탄화하여 병합 (Prefix: NetworkProtocol)
|
||||
flat_net = self._flatten_dict(filtered_net, parent_key='NetworkProtocol')
|
||||
all_attributes.update(flat_net)
|
||||
|
||||
# 3. Account Service 조회 (LDAP, ActiveDirectory)
|
||||
# /redfish/v1/AccountService
|
||||
acc_url = f"{self.base_url}/redfish/v1/AccountService"
|
||||
acc_res = self.session.get(acc_url, timeout=10)
|
||||
if acc_res.status_code == 200:
|
||||
acc_data = acc_res.json()
|
||||
exclude_keys = ['@odata.context', '@odata.id', '@odata.type', 'Name', 'Description', 'Id', 'Status', 'Roles', 'Accounts']
|
||||
filtered_acc = {k: v for k, v in acc_data.items() if k not in exclude_keys}
|
||||
|
||||
flat_acc = self._flatten_dict(filtered_acc, parent_key='AccountService')
|
||||
all_attributes.update(flat_acc)
|
||||
|
||||
return all_attributes
|
||||
|
||||
return all_attributes
|
||||
|
||||
except Exception as e:
|
||||
print(f"iDRAC 속성 조회 오류: {str(e)}")
|
||||
return all_attributes or {}
|
||||
|
||||
def get_idrac_attribute_registry(self):
|
||||
"""
|
||||
iDRAC Attribute Registry를 조회하여 속성 이름(AttributeName)과
|
||||
사용자 친화적인 이름(DisplayName)의 매핑 정보를 반환합니다.
|
||||
|
||||
Returns:
|
||||
dict: {AttributeName: DisplayName, ...}
|
||||
"""
|
||||
try:
|
||||
# 1. iDRAC 리소스에서 AttributeRegistry 정보 확인
|
||||
url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/Attributes"
|
||||
response = self.session.get(url, timeout=15)
|
||||
|
||||
if response.status_code != 200:
|
||||
print("iDRAC 리소스 조회 실패")
|
||||
return {}
|
||||
|
||||
data = response.json()
|
||||
registry_name = data.get('AttributeRegistry')
|
||||
|
||||
if not registry_name:
|
||||
print("iDRAC Attribute Registry 정보를 찾을 수 없음. 기본값으로 시도합니다.")
|
||||
# iDRAC의 경우 보통 'iDRACRegistry' 또는 'IdracAttributeRegistry' 등일 수 있음
|
||||
# 하지만 대부분의 Redfish 구현체에서 AttributeRegistry 프로퍼티가 있거나
|
||||
# Registries 컬렉션에서 찾을 수 있습니다.
|
||||
registry_name = "iDRACAttributeRegistry"
|
||||
|
||||
print(f"DEBUG: Looking for iDRAC Registry: {registry_name}")
|
||||
|
||||
# 2. Registries 컬렉션에서 해당 레지스트리 찾기
|
||||
reg_url = f"{self.base_url}/redfish/v1/Registries"
|
||||
reg_res = self.session.get(reg_url, timeout=10)
|
||||
|
||||
target_location = None
|
||||
|
||||
if reg_res.status_code == 200:
|
||||
reg_data = reg_res.json()
|
||||
|
||||
# 검색할 이름 후보군 (버전 제거 등)
|
||||
search_candidates = [registry_name]
|
||||
if '.' in registry_name:
|
||||
search_candidates.append(registry_name.split('.')[0])
|
||||
|
||||
# 'iDRACAttributeRegistry'가 없다면 추가 (하드코딩 폴백)
|
||||
if 'iDRACAttributeRegistry' not in search_candidates:
|
||||
search_candidates.append('iDRACAttributeRegistry')
|
||||
if 'iDRACRegistry' not in search_candidates:
|
||||
search_candidates.append('iDRACRegistry')
|
||||
|
||||
print(f"DEBUG: iDRAC Registry Candidates: {search_candidates}")
|
||||
|
||||
for member in reg_data.get('Members', []):
|
||||
m_url = member.get('@odata.id', '')
|
||||
m_id = m_url.rstrip('/').split('/')[-1]
|
||||
|
||||
match_found = False
|
||||
for candidate in search_candidates:
|
||||
if candidate.lower() in m_id.lower():
|
||||
match_found = True
|
||||
break
|
||||
|
||||
if match_found:
|
||||
print(f"DEBUG: Found iDRAC Registry Match: {m_url}")
|
||||
# 레지스트리 상세 조회
|
||||
detail_url = f"{self.base_url}{m_url}"
|
||||
detail_res = self.session.get(detail_url, timeout=10)
|
||||
if detail_res.status_code == 200:
|
||||
detail_data = detail_res.json()
|
||||
# Location 정보 찾기 (영어 버전 선호)
|
||||
locations = detail_data.get('Location', [])
|
||||
for loc in locations:
|
||||
if loc.get('Language', '').startswith('en'):
|
||||
target_location = loc.get('Uri')
|
||||
break
|
||||
if not target_location and locations:
|
||||
target_location = locations[0].get('Uri')
|
||||
|
||||
if target_location:
|
||||
break
|
||||
|
||||
# 3. Registry 파일(JSON) 다운로드 및 파싱
|
||||
if target_location:
|
||||
# URI가 전체 경로가 아닐 수 있음
|
||||
if not target_location.startswith('http'):
|
||||
final_url = f"{self.base_url}{target_location}"
|
||||
else:
|
||||
final_url = target_location
|
||||
|
||||
print(f"Downloading iDRAC Registry from: {final_url}")
|
||||
file_res = self.session.get(final_url, timeout=30)
|
||||
|
||||
if file_res.status_code == 200:
|
||||
reg_json = file_res.json()
|
||||
mapping = {}
|
||||
|
||||
# RegistryEntries 파싱
|
||||
attributes = []
|
||||
if 'RegistryEntries' in reg_json:
|
||||
if 'Attributes' in reg_json['RegistryEntries']:
|
||||
attributes = reg_json['RegistryEntries']['Attributes']
|
||||
else:
|
||||
pass
|
||||
elif 'Attributes' in reg_json:
|
||||
attributes = reg_json['Attributes']
|
||||
|
||||
for entry in attributes:
|
||||
attr_name = entry.get('AttributeName')
|
||||
display_name = entry.get('DisplayName')
|
||||
if attr_name and display_name:
|
||||
mapping[attr_name] = display_name
|
||||
|
||||
print(f"iDRAC Registry 파싱 완료: {len(mapping)}개 항목")
|
||||
return mapping
|
||||
|
||||
print(f"iDRAC Registry 위치를 찾을 수 없음 (TargetLocation: {target_location})")
|
||||
return {}
|
||||
|
||||
except Exception as e:
|
||||
print(f"iDRAC Attribute Registry 조회 오류: {str(e)}")
|
||||
return {}
|
||||
|
||||
def get_system_info_detailed(self):
|
||||
"""
|
||||
시스템 상세 정보 (CPU, Memory, Model 등) 조회
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1"
|
||||
response = self.session.get(url, timeout=15)
|
||||
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
|
||||
info = {
|
||||
'Model': data.get('Model', 'Unknown'),
|
||||
'Manufacturer': data.get('Manufacturer', 'Unknown'),
|
||||
'SerialNumber': data.get('SerialNumber', 'Unknown'),
|
||||
'PowerState': data.get('PowerState', 'Unknown'),
|
||||
'BiosVersion': data.get('BiosVersion', 'Unknown'),
|
||||
'ProcessorSummary': {
|
||||
'Count': data.get('ProcessorSummary', {}).get('Count', 0),
|
||||
'Model': data.get('ProcessorSummary', {}).get('Model', 'Unknown'),
|
||||
'Status': data.get('ProcessorSummary', {}).get('Status', {}).get('Health', 'Unknown')
|
||||
},
|
||||
'MemorySummary': {
|
||||
'TotalSystemMemoryGiB': data.get('MemorySummary', {}).get('TotalSystemMemoryGiB', 0),
|
||||
'Status': data.get('MemorySummary', {}).get('Status', {}).get('Health', 'Unknown')
|
||||
},
|
||||
'HostName': data.get('HostName', '')
|
||||
}
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
print(f"시스템 상세 조회 오류: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_storage_configuration(self):
|
||||
"""
|
||||
RAID 컨트롤러 및 스토리지 구성 정보 조회
|
||||
Controllers, Volumes(RAID), Drives 정보 포함
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1/Storage"
|
||||
response = self.session.get(url, timeout=20)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"스토리지 조회 실패: {response.status_code}")
|
||||
return {'Controllers': []}
|
||||
|
||||
data = response.json()
|
||||
controllers = []
|
||||
|
||||
for member in data.get('Members', []):
|
||||
try:
|
||||
ctrl_url = f"{self.base_url}{member['@odata.id']}"
|
||||
ctrl_res = self.session.get(ctrl_url, timeout=10)
|
||||
|
||||
if ctrl_res.status_code != 200:
|
||||
continue
|
||||
|
||||
ctrl_data = ctrl_res.json()
|
||||
|
||||
# 1. 컨트롤러 기본 정보
|
||||
controller = {
|
||||
'Name': ctrl_data.get('Name', 'Unknown'),
|
||||
'Id': ctrl_data.get('Id', ''),
|
||||
'Model': ctrl_data.get('Model', ''),
|
||||
'FirmwareVersion': '', # 아래에서 찾음
|
||||
'Status': ctrl_data.get('Status', {}).get('Health', 'Unknown'),
|
||||
'Volumes': [],
|
||||
'Drives': []
|
||||
}
|
||||
|
||||
# 펌웨어 버전 찾기 (Controllers 리스트 내)
|
||||
for c in ctrl_data.get('Controllers', []):
|
||||
if 'FirmwareVersion' in c:
|
||||
controller['FirmwareVersion'] = c['FirmwareVersion']
|
||||
break
|
||||
|
||||
# 2. Volumes (Virtual Disks)
|
||||
# Volumes가 링크로 되어있는 경우와 바로 있는 경우가 있음
|
||||
volumes_url = f"{ctrl_url}/Volumes"
|
||||
vol_res = self.session.get(volumes_url, timeout=10)
|
||||
|
||||
if vol_res.status_code == 200:
|
||||
vol_data = vol_res.json()
|
||||
for vol_member in vol_data.get('Members', []):
|
||||
v_url = f"{self.base_url}{vol_member['@odata.id']}"
|
||||
v_res = self.session.get(v_url, timeout=10)
|
||||
if v_res.status_code == 200:
|
||||
v_data = v_res.json()
|
||||
controller['Volumes'].append({
|
||||
'Name': v_data.get('Name', ''),
|
||||
'RaidType': v_data.get('RAIDType', 'Unknown'), # RAID0, RAID1, etc
|
||||
'CapacityBytes': v_data.get('CapacityBytes', 0),
|
||||
'BlockSizeBytes': v_data.get('BlockSizeBytes', 0),
|
||||
'Status': v_data.get('Status', {}).get('Health', 'Unknown')
|
||||
})
|
||||
|
||||
# 3. Drives (Physical Disks)
|
||||
drives_url = f"{ctrl_url}/Drives"
|
||||
drv_res = self.session.get(drives_url, timeout=10)
|
||||
|
||||
if drv_res.status_code == 200:
|
||||
drv_data = drv_res.json()
|
||||
for drv_member in drv_data.get('Members', []):
|
||||
d_url = f"{self.base_url}{drv_member['@odata.id']}"
|
||||
d_res = self.session.get(d_url, timeout=10)
|
||||
if d_res.status_code == 200:
|
||||
d_data = d_res.json()
|
||||
controller['Drives'].append({
|
||||
'Name': d_data.get('Name', ''),
|
||||
'Model': d_data.get('Model', ''),
|
||||
'Protocol': d_data.get('Protocol', 'Unknown'), # SATA, SAS, NVMe
|
||||
'MediaType': d_data.get('MediaType', 'Unknown'), # HDD, SSD
|
||||
'CapacityBytes': d_data.get('CapacityBytes', 0),
|
||||
'Status': d_data.get('Status', {}).get('Health', 'Unknown')
|
||||
})
|
||||
|
||||
controllers.append(controller)
|
||||
|
||||
except Exception as e:
|
||||
print(f"컨트롤러 상세 조회 오류 ({member.get('@odata.id')}): {str(e)}")
|
||||
|
||||
return {'Controllers': controllers}
|
||||
|
||||
except Exception as e:
|
||||
print(f"스토리지 구성 조회 오류: {str(e)}")
|
||||
return {'Controllers': []}
|
||||
|
||||
def get_nic_configuration(self):
|
||||
"""
|
||||
NIC 구성 정보 조회 (NetworkAdapters + DeviceFunctions)
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1/NetworkAdapters"
|
||||
response = self.session.get(url, timeout=15)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"NIC 어댑터 목록 조회 실패: {response.status_code}")
|
||||
return {'Adapters': []}
|
||||
|
||||
data = response.json()
|
||||
adapters = []
|
||||
|
||||
for member in data.get('Members', []):
|
||||
try:
|
||||
# 어댑터 기본 정보
|
||||
adp_url = f"{self.base_url}{member['@odata.id']}"
|
||||
adp_res = self.session.get(adp_url, timeout=10)
|
||||
if adp_res.status_code != 200:
|
||||
continue
|
||||
|
||||
adp_data = adp_res.json()
|
||||
adapter = {
|
||||
'Id': adp_data.get('Id', ''),
|
||||
'Name': adp_data.get('Name', 'Unknown'),
|
||||
'Manufacturer': adp_data.get('Manufacturer', ''),
|
||||
'Model': adp_data.get('Model', ''),
|
||||
'SerialNumber': adp_data.get('SerialNumber', ''),
|
||||
'FirmwareVersion': '', # LinkStatus 등은 Port에 있음
|
||||
'DeviceFunctions': []
|
||||
}
|
||||
|
||||
# 펌웨어 버전 추출
|
||||
if 'FirmwarePackageVersion' in adp_data:
|
||||
adapter['FirmwareVersion'] = adp_data['FirmwarePackageVersion']
|
||||
|
||||
# NetworkDeviceFunctions (iSCSI, PXE 등 설정)
|
||||
funcs_url = f"{adp_url}/NetworkDeviceFunctions"
|
||||
funcs_res = self.session.get(funcs_url, timeout=10)
|
||||
|
||||
if funcs_res.status_code == 200:
|
||||
funcs_data = funcs_res.json()
|
||||
for f_mem in funcs_data.get('Members', []):
|
||||
f_url = f"{self.base_url}{f_mem['@odata.id']}"
|
||||
f_res = self.session.get(f_url, timeout=10)
|
||||
if f_res.status_code == 200:
|
||||
f_data = f_res.json()
|
||||
# Dell 특화 Attributes 또는 표준 Redfish 속성
|
||||
func_info = {
|
||||
'Id': f_data.get('Id', ''),
|
||||
'Name': f_data.get('Name', ''),
|
||||
'NetBootPMMode': f_data.get('BootMode', 'Unknown'), # Legacy, UEFI
|
||||
'iSCSIBoot': {},
|
||||
'Ethernet': {}
|
||||
}
|
||||
|
||||
# iSCSI Boot 속성 평탄화
|
||||
if 'iSCSIBoot' in f_data:
|
||||
func_info['iSCSIBoot'] = f_data['iSCSIBoot']
|
||||
|
||||
# 이더넷 속성 (MAC 등)
|
||||
if 'Ethernet' in f_data:
|
||||
func_info['Ethernet'] = {
|
||||
'MACAddress': f_data['Ethernet'].get('MACAddress', ''),
|
||||
'MTUSize': f_data['Ethernet'].get('MTUSize', '')
|
||||
}
|
||||
|
||||
adapter['DeviceFunctions'].append(func_info)
|
||||
|
||||
adapters.append(adapter)
|
||||
|
||||
except Exception as e:
|
||||
print(f"NIC 상세 조회 오류: {str(e)}")
|
||||
|
||||
return {'Adapters': adapters}
|
||||
|
||||
except Exception as e:
|
||||
print(f"NIC 구성 조회 오류: {str(e)}")
|
||||
return {'Adapters': []}
|
||||
|
||||
def get_boot_settings(self):
|
||||
"""
|
||||
부팅 설정 조회 (Boot Order, Boot Source Override)
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1"
|
||||
response = self.session.get(url, timeout=15)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
boot = data.get('Boot', {})
|
||||
return {
|
||||
'BootOrder': boot.get('BootOrder', []),
|
||||
'BootSourceOverrideTarget': boot.get('BootSourceOverrideTarget', 'None'),
|
||||
'BootSourceOverrideMode': boot.get('BootSourceOverrideMode', 'Legacy'),
|
||||
'BootSourceOverrideEnabled': boot.get('BootSourceOverrideEnabled', 'Disabled')
|
||||
}
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f"부팅 설정 조회 오류: {str(e)}")
|
||||
return {}
|
||||
|
||||
def get_firmware_baseline(self):
|
||||
"""
|
||||
Baseline 비교용 펌웨어 목록 (간소화)
|
||||
"""
|
||||
try:
|
||||
inventory = self.get_firmware_inventory()
|
||||
baseline_fw = {}
|
||||
for item in inventory:
|
||||
# 키: 컴포넌트 이름 (중복 시 타입/ID 병기하여 유니크하게 만들 필요 있음)
|
||||
# 여기서는 단순화를 위해 Name을 Key로 사용하되, 중복되면 덮어씀 (일반적으로 주요 컴포넌트는 유니크함)
|
||||
# 또는 {ComponentType}_{Name} 형식 추천
|
||||
key = item['Name']
|
||||
baseline_fw[key] = item['Version']
|
||||
return baseline_fw
|
||||
except Exception as e:
|
||||
print(f"펌웨어 Baseline 조회 오류: {str(e)}")
|
||||
return {}
|
||||
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import logging
|
||||
import subprocess
|
||||
import platform
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from threading import Lock
|
||||
from watchdog.observers import Observer
|
||||
from backend.services.watchdog_handler import FileCreatedHandler
|
||||
from config import Config
|
||||
|
||||
# Job ID별 진행률 (스레드 안전)
|
||||
_progress: dict[str, int] = {}
|
||||
_progress_lock = Lock()
|
||||
|
||||
|
||||
def _set_progress(job_id: str, value: int) -> None:
|
||||
with _progress_lock:
|
||||
_progress[job_id] = max(0, min(100, int(value)))
|
||||
|
||||
|
||||
def get_progress(job_id: str) -> int:
|
||||
with _progress_lock:
|
||||
return int(_progress.get(job_id, 0))
|
||||
|
||||
|
||||
def on_complete(job_id: str) -> None:
|
||||
_set_progress(job_id, 100)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# IP 목록 저장
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def save_ip_addresses(ips: str, folder: str | os.PathLike[str]) -> list[tuple[str, str]]:
|
||||
out_dir = Path(folder)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ip_files: list[tuple[str, str]] = []
|
||||
for i, raw in enumerate((ips or "").splitlines()):
|
||||
ip = raw.strip()
|
||||
if not ip:
|
||||
continue
|
||||
file_path = out_dir / f"ip_{i}.txt"
|
||||
file_path.write_text(ip + "\n", encoding="utf-8")
|
||||
ip_files.append((ip, str(file_path)))
|
||||
return ip_files
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 개별 IP 처리
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_command(script: str, ip_file: str, xml_file: str | None, profile_name: str | None = None) -> list[str]:
|
||||
# unified 스크립트는 data/scripts/unified 폴더에 있음
|
||||
# 기존 레거시 스크립트는 data/scripts 폴더에 있음
|
||||
# script 인자가 "unified/..." 로 시작하면 해당 경로 사용, 아니면 data/scripts 기준
|
||||
|
||||
if script.startswith("unified/"):
|
||||
script_path = Path(Config.SCRIPT_FOLDER) / script
|
||||
else:
|
||||
script_path = Path(Config.SCRIPT_FOLDER) / script
|
||||
|
||||
if not script_path.exists():
|
||||
raise FileNotFoundError(f"스크립트를 찾을 수 없습니다: {script_path}")
|
||||
|
||||
if script_path.suffix == ".sh":
|
||||
# Windows에서 .sh 실행은 bash 필요 (Git Bash/WSL 등). 없으면 예외 처리.
|
||||
if platform.system() == "Windows":
|
||||
bash = shutil.which("bash") # type: ignore[name-defined]
|
||||
if not bash:
|
||||
raise RuntimeError("Windows에서 .sh 스크립트를 실행하려면 bash가 필요합니다.")
|
||||
cmd = [bash, str(script_path), ip_file]
|
||||
else:
|
||||
cmd = [str(script_path), ip_file]
|
||||
elif script_path.suffix == ".py":
|
||||
cmd = [sys.executable, str(script_path), ip_file]
|
||||
else:
|
||||
raise ValueError(f"지원되지 않는 스크립트 형식: {script_path.suffix}")
|
||||
|
||||
# 프로파일이 있으면 추가 (unified 스크립트용)
|
||||
if profile_name:
|
||||
cmd.append(profile_name)
|
||||
|
||||
if xml_file:
|
||||
cmd.append(xml_file)
|
||||
return cmd
|
||||
|
||||
|
||||
def process_ip(ip_file: str, script: str, xml_file: str | None = None, profile_name: str | None = None, slot_priority: str | None = None) -> None:
|
||||
ip = Path(ip_file).read_text(encoding="utf-8").strip()
|
||||
cmd = _build_command(script, ip_file, xml_file, profile_name)
|
||||
|
||||
# 환경 변수 설정 (기존 환경 변수 복사 + 슬롯 우선순위 추가)
|
||||
env = os.environ.copy()
|
||||
if slot_priority:
|
||||
env["GUID_SLOT_PRIORITY"] = slot_priority
|
||||
|
||||
logging.info("🔧 실행 명령: %s", " ".join(cmd))
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=str(Path(Config.SCRIPT_FOLDER)),
|
||||
timeout=int(os.getenv("SCRIPT_TIMEOUT", "1800")), # 30분 기본
|
||||
env=env,
|
||||
)
|
||||
logging.info("[%s] ✅ stdout:\n%s", ip, result.stdout)
|
||||
if result.stderr:
|
||||
logging.warning("[%s] ⚠ stderr:\n%s", ip, result.stderr)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error("[%s] ❌ 스크립트 실행 오류(code=%s): %s", ip, e.returncode, e.stderr or e)
|
||||
except subprocess.TimeoutExpired:
|
||||
logging.error("[%s] ⏰ 스크립트 실행 타임아웃", ip)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 병렬 처리 진입점
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def process_ips_concurrently(ip_files, job_id, observer: Observer, script: str, xml_file: str | None, profile_name: str | None = None, slot_priority: str | None = None):
|
||||
total = len(ip_files)
|
||||
completed = 0
|
||||
_set_progress(job_id, 0)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=Config.MAX_WORKERS) as pool:
|
||||
futures = {pool.submit(process_ip, ip_path, script, xml_file, profile_name, slot_priority): ip for ip, ip_path in ip_files}
|
||||
for fut in as_completed(futures):
|
||||
ip = futures[fut]
|
||||
try:
|
||||
fut.result()
|
||||
except Exception as e:
|
||||
logging.error("%s 처리 중 오류 발생: %s", ip, e)
|
||||
finally:
|
||||
completed += 1
|
||||
if total:
|
||||
_set_progress(job_id, int(completed * 100 / total))
|
||||
finally:
|
||||
try:
|
||||
observer.stop()
|
||||
observer.join(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 외부에서 한 번에 처리(동기)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def handle_ip_processing(ip_text: str, script: str, xml_file: str | None = None, profile_name: str | None = None) -> str:
|
||||
job_id = str(uuid.uuid4())
|
||||
|
||||
temp_dir = Path(Config.UPLOAD_FOLDER) / job_id
|
||||
ip_files = save_ip_addresses(ip_text, temp_dir)
|
||||
|
||||
xml_path = str(Path(Config.XML_FOLDER) / xml_file) if xml_file else None
|
||||
|
||||
handler = FileCreatedHandler(job_id, len(ip_files))
|
||||
observer = Observer()
|
||||
observer.schedule(handler, Config.IDRAC_INFO_FOLDER, recursive=False)
|
||||
observer.start()
|
||||
|
||||
process_ips_concurrently(ip_files, job_id, observer, script, xml_path, profile_name, None)
|
||||
return job_id
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from typing import Optional
|
||||
from config import Config
|
||||
|
||||
|
||||
_DEF_LEVEL = os.getenv("APP_LOG_LEVEL", "INFO").upper()
|
||||
_DEF_FMT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
|
||||
|
||||
def _ensure_log_dir() -> Path:
|
||||
p = Path(Config.LOG_FOLDER)
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def setup_logging(app: Optional[object] = None) -> logging.Logger:
|
||||
"""앱 전역 로깅을 파일(일단위 회전) + 콘솔로 설정.
|
||||
- 회전 파일명: YYYY-MM-DD.log
|
||||
- 중복 핸들러 방지
|
||||
- Windows/Linux 공통 동작
|
||||
"""
|
||||
log_dir = _ensure_log_dir()
|
||||
log_path = log_dir / "app.log"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# [Fix] 앱 시작 시점에 app.log가 있고 날짜가 지났다면 강제 로테이션 (백업)
|
||||
# TimedRotatingFileHandler는 프로세스가 재시작되면 파일의 생성일을 기준으로
|
||||
# 롤오버를 즉시 수행하지 않고 append 모드로 여는 경우가 많음.
|
||||
# 이를 보완하기 위해, 직접 날짜를 확인하고 파일을 옮겨준다.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
if log_path.exists():
|
||||
try:
|
||||
from datetime import date
|
||||
|
||||
# 파일 마지막 수정 시간 확인
|
||||
mtime = os.path.getmtime(log_path)
|
||||
file_date = date.fromtimestamp(mtime)
|
||||
today = date.today()
|
||||
|
||||
# "파일 날짜" < "오늘" 이면 백업
|
||||
if file_date < today:
|
||||
backup_name = file_date.strftime("%Y-%m-%d.log")
|
||||
backup_path = log_dir / backup_name
|
||||
|
||||
# 이미 백업 파일이 있으면 굳이 덮어쓰거나 하지 않음(안전성)
|
||||
if not backup_path.exists():
|
||||
os.rename(log_path, backup_path)
|
||||
print(f"[Logger] Rotated old log file: app.log -> {backup_name}")
|
||||
|
||||
except Exception as e:
|
||||
# 권한 문제, 파일 잠금(Windows) 등으로 실패 시 무시하고 진행
|
||||
print(f"[Logger] Failed to force rotate log file: {e}")
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(_DEF_LEVEL)
|
||||
root.propagate = False
|
||||
|
||||
# 기존 핸들러 제거(중복 방지)
|
||||
for h in root.handlers[:]:
|
||||
root.removeHandler(h)
|
||||
|
||||
# 파일 로거
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
filename=str(log_path), when="midnight", interval=1, backupCount=90, encoding="utf-8", utc=False
|
||||
)
|
||||
|
||||
# 회전 파일명: 2025-09-30.log 형태로
|
||||
def _namer(default_name: str) -> str:
|
||||
# default_name: app.log.YYYY-MM-DD
|
||||
base_dir = os.path.dirname(default_name)
|
||||
date_str = default_name.rsplit(".", 1)[-1]
|
||||
return os.path.join(base_dir, f"{date_str}.log")
|
||||
|
||||
file_handler.namer = _namer
|
||||
file_handler.setFormatter(logging.Formatter(_DEF_FMT))
|
||||
|
||||
# 콘솔 로거
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
|
||||
|
||||
root.addHandler(file_handler)
|
||||
root.addHandler(console)
|
||||
|
||||
if app is not None:
|
||||
# Flask 앱 로거에도 동일 핸들러 바인딩
|
||||
app.logger.handlers = root.handlers
|
||||
app.logger.setLevel(root.level)
|
||||
# 루트 로거로 전파되면 메시지가 두 번 출력되므로 방지
|
||||
app.logger.propagate = False
|
||||
|
||||
# 제3자 라이브러리 로그 레벨 조정 (너무 시끄러운 경우)
|
||||
# werkzeug: 기본적인 HTTP 요청 로그(GET/POST 등)를 숨김 (WARNING 이상만 표시)
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
logging.getLogger("socketio").setLevel(logging.WARNING)
|
||||
logging.getLogger("engineio").setLevel(logging.WARNING)
|
||||
|
||||
# httpx, telegram 라이브러리의 HTTP 요청 로그 숨기기
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logging.getLogger("telegram").setLevel(logging.WARNING)
|
||||
|
||||
root.debug("Logger initialized | level=%s | file=%s", _DEF_LEVEL, log_path)
|
||||
return root
|
||||
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Dell iDRAC Redfish API Client (수정 버전)
|
||||
절대 경로와 상대 경로 모두 처리
|
||||
"""
|
||||
import requests
|
||||
import urllib3
|
||||
from typing import Dict, Any, Optional, List
|
||||
import logging
|
||||
from functools import wraps
|
||||
import time
|
||||
import os
|
||||
|
||||
# SSL 경고 비활성화
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def retry_on_failure(max_attempts: int = 2, delay: float = 2.0):
|
||||
"""재시도 데코레이터"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
last_exception = None
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except (requests.Timeout, requests.ConnectionError) as e:
|
||||
last_exception = e
|
||||
if attempt < max_attempts - 1:
|
||||
logger.warning(f"Attempt {attempt + 1} failed, retrying in {delay}s: {e}")
|
||||
time.sleep(delay * (attempt + 1))
|
||||
except Exception as e:
|
||||
raise
|
||||
raise last_exception
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
class RedfishClient:
|
||||
"""Dell iDRAC Redfish API 클라이언트"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ip: str,
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: Optional[int] = None,
|
||||
verify_ssl: Optional[bool] = None
|
||||
):
|
||||
self.ip = ip
|
||||
self.base_url = f"https://{ip}/redfish/v1"
|
||||
self.host_url = f"https://{ip}"
|
||||
|
||||
# Config defaults
|
||||
default_timeout = 15
|
||||
default_verify = False
|
||||
|
||||
try:
|
||||
from flask import current_app
|
||||
if current_app:
|
||||
default_timeout = current_app.config.get("REDFISH_TIMEOUT", 15)
|
||||
default_verify = current_app.config.get("REDFISH_VERIFY_SSL", False)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
self.timeout = timeout if timeout is not None else default_timeout
|
||||
self.verify_ssl = verify_ssl if verify_ssl is not None else default_verify
|
||||
|
||||
self.session = requests.Session()
|
||||
self.session.auth = (username, password)
|
||||
self.session.verify = self.verify_ssl
|
||||
self.session.headers.update({
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
})
|
||||
|
||||
@retry_on_failure(max_attempts=2, delay=2.0)
|
||||
def get(self, endpoint: str) -> Dict[str, Any]:
|
||||
"""
|
||||
GET 요청
|
||||
절대 경로와 상대 경로 모두 처리
|
||||
"""
|
||||
# 절대 경로 처리 (이미 /redfish/v1로 시작하는 경우)
|
||||
if endpoint.startswith('/redfish/v1'):
|
||||
url = f"{self.host_url}{endpoint}"
|
||||
# 상대 경로 처리
|
||||
else:
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
|
||||
logger.debug(f"GET {url}")
|
||||
response = self.session.get(url, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_jobs(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
모든 Job 조회
|
||||
표준 경로와 Dell OEM 경로 모두 시도
|
||||
"""
|
||||
jobs = []
|
||||
|
||||
# 1. 표준 Redfish Jobs 경로 시도
|
||||
try:
|
||||
standard_jobs = self._get_jobs_standard()
|
||||
jobs.extend(standard_jobs)
|
||||
except Exception as e:
|
||||
logger.warning(f"{self.ip}: Standard Jobs endpoint failed: {e}")
|
||||
|
||||
# 2. Dell OEM Jobs 경로 시도
|
||||
try:
|
||||
oem_jobs = self._get_jobs_dell_oem()
|
||||
jobs.extend(oem_jobs)
|
||||
except Exception as e:
|
||||
logger.warning(f"{self.ip}: Dell OEM Jobs endpoint failed: {e}")
|
||||
|
||||
if not jobs:
|
||||
logger.info(f"{self.ip}: No jobs found")
|
||||
return []
|
||||
|
||||
# 중복 제거 (JID 기준)
|
||||
seen_jids = set()
|
||||
unique_jobs = []
|
||||
for job in jobs:
|
||||
jid = job.get("JID", "")
|
||||
if jid and jid not in seen_jids:
|
||||
seen_jids.add(jid)
|
||||
unique_jobs.append(job)
|
||||
|
||||
logger.info(f"{self.ip}: Retrieved {len(unique_jobs)} unique jobs")
|
||||
return sorted(unique_jobs, key=lambda x: x.get("JID", ""))
|
||||
|
||||
def _get_jobs_standard(self) -> List[Dict[str, Any]]:
|
||||
"""표준 Redfish Jobs 조회"""
|
||||
jobs_endpoint = "/Managers/iDRAC.Embedded.1/Jobs"
|
||||
jobs_collection = self.get(jobs_endpoint)
|
||||
|
||||
members = jobs_collection.get("Members", [])
|
||||
if not members:
|
||||
return []
|
||||
|
||||
jobs = []
|
||||
for member in members:
|
||||
job_path = member.get("@odata.id", "")
|
||||
if not job_path:
|
||||
continue
|
||||
|
||||
try:
|
||||
job_data = self.get(job_path)
|
||||
normalized_job = self._normalize_job(job_data)
|
||||
jobs.append(normalized_job)
|
||||
except Exception as e:
|
||||
logger.warning(f"{self.ip}: Failed to get job {job_path}: {e}")
|
||||
continue
|
||||
|
||||
return jobs
|
||||
|
||||
def _get_jobs_dell_oem(self) -> List[Dict[str, Any]]:
|
||||
"""Dell OEM Jobs 조회"""
|
||||
oem_endpoint = "/Managers/iDRAC.Embedded.1/Oem/Dell/Jobs"
|
||||
|
||||
try:
|
||||
jobs_collection = self.get(oem_endpoint)
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"{self.ip}: Dell OEM endpoint not available")
|
||||
return []
|
||||
raise
|
||||
|
||||
members = jobs_collection.get("Members", [])
|
||||
if not members:
|
||||
return []
|
||||
|
||||
jobs = []
|
||||
for member in members:
|
||||
job_path = member.get("@odata.id", "")
|
||||
if not job_path:
|
||||
continue
|
||||
|
||||
try:
|
||||
job_data = self.get(job_path)
|
||||
normalized_job = self._normalize_job(job_data)
|
||||
jobs.append(normalized_job)
|
||||
except Exception as e:
|
||||
logger.warning(f"{self.ip}: Failed to get Dell OEM job {job_path}: {e}")
|
||||
continue
|
||||
|
||||
return jobs
|
||||
|
||||
def _normalize_job(self, job_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Redfish Job 데이터를 표준 포맷으로 변환"""
|
||||
percent = job_data.get("PercentComplete", 0)
|
||||
if percent is None:
|
||||
percent = 0
|
||||
|
||||
# JobState 매핑
|
||||
job_state = job_data.get("JobState", "Unknown")
|
||||
status_map = {
|
||||
"New": "Scheduled",
|
||||
"Starting": "Starting",
|
||||
"Running": "Running",
|
||||
"Completed": "Completed",
|
||||
"Failed": "Failed",
|
||||
"CompletedWithErrors": "Completed with Errors",
|
||||
"Pending": "Pending",
|
||||
"Paused": "Paused",
|
||||
"Stopping": "Stopping",
|
||||
"Cancelled": "Cancelled",
|
||||
"Cancelling": "Cancelling"
|
||||
}
|
||||
status = status_map.get(job_state, job_state)
|
||||
|
||||
# 메시지 처리
|
||||
messages = job_data.get("Messages", [])
|
||||
message_text = ""
|
||||
if messages and isinstance(messages, list):
|
||||
if messages[0] and isinstance(messages[0], dict):
|
||||
message_text = messages[0].get("Message", "")
|
||||
|
||||
if not message_text:
|
||||
message_text = job_data.get("Message", "")
|
||||
|
||||
return {
|
||||
"JID": job_data.get("Id", ""),
|
||||
"Name": job_data.get("Name", ""),
|
||||
"Status": status,
|
||||
"PercentComplete": str(percent),
|
||||
"Message": message_text,
|
||||
"ScheduledStartTime": job_data.get("ScheduledStartTime", ""),
|
||||
"LastUpdateTime": job_data.get("EndTime") or job_data.get("StartTime", ""),
|
||||
}
|
||||
|
||||
def export_system_configuration(self, share_parameters: Dict[str, Any], target: str = "ALL") -> str:
|
||||
"""
|
||||
시스템 설정 내보내기 (SCP Export)
|
||||
:param share_parameters: 네트워크 공유 설정 (IP, ShareName, FileName, UserName, Password 등)
|
||||
:param target: 내보낼 대상 (ALL, IDRAC, BIOS, NIC, RAID)
|
||||
:return: Job ID
|
||||
"""
|
||||
url = f"{self.host_url}/redfish/v1/Managers/iDRAC.Embedded.1/Actions/Oem/DellManager.ExportSystemConfiguration"
|
||||
|
||||
payload = {
|
||||
"ExportFormat": "XML",
|
||||
"ShareParameters": share_parameters,
|
||||
"Target": target
|
||||
}
|
||||
|
||||
logger.info(f"{self.ip}: Exporting system configuration to {share_parameters.get('FileName')}")
|
||||
response = self.session.post(url, json=payload, verify=False)
|
||||
response.raise_for_status()
|
||||
|
||||
# Job ID 추출 (Location 헤더 또는 응답 본문)
|
||||
job_id = ""
|
||||
if response.status_code == 202:
|
||||
location = response.headers.get("Location")
|
||||
if location:
|
||||
job_id = location.split("/")[-1]
|
||||
|
||||
if not job_id:
|
||||
# 응답 본문에서 시도 (일부 펌웨어 버전 대응)
|
||||
try:
|
||||
data = response.json()
|
||||
# 일반적인 JID 포맷 확인 필요
|
||||
# 여기서는 간단히 헤더 우선으로 처리하고 없으면 에러 처리하거나 데이터 파싱
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
return job_id
|
||||
|
||||
def import_system_configuration(self, share_parameters: Dict[str, Any], import_mode: str = "Replace", target: str = "ALL") -> str:
|
||||
"""
|
||||
시스템 설정 가져오기 (SCP Import)
|
||||
:param share_parameters: 네트워크 공유 설정
|
||||
:param import_mode: Replace, Append 등
|
||||
:param target: 가져올 대상
|
||||
:return: Job ID
|
||||
"""
|
||||
url = f"{self.host_url}/redfish/v1/Managers/iDRAC.Embedded.1/Actions/Oem/DellManager.ImportSystemConfiguration"
|
||||
|
||||
payload = {
|
||||
"ImportSystemConfigurationXMLFile": share_parameters.get("FileName"),
|
||||
"ShareParameters": share_parameters,
|
||||
"ImportMode": import_mode,
|
||||
"Target": target,
|
||||
"ShutdownType": "Graceful" # 적용 후 재부팅 방식
|
||||
}
|
||||
|
||||
logger.info(f"{self.ip}: Importing system configuration from {share_parameters.get('FileName')}")
|
||||
response = self.session.post(url, json=payload, verify=False)
|
||||
response.raise_for_status()
|
||||
|
||||
job_id = ""
|
||||
if response.status_code == 202:
|
||||
location = response.headers.get("Location")
|
||||
if location:
|
||||
job_id = location.split("/")[-1]
|
||||
|
||||
return job_id
|
||||
|
||||
def close(self):
|
||||
"""세션 종료"""
|
||||
self.session.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
|
||||
# 커스텀 예외
|
||||
class AuthenticationError(Exception):
|
||||
"""인증 실패"""
|
||||
pass
|
||||
|
||||
|
||||
class NotSupportedError(Exception):
|
||||
"""지원하지 않는 기능"""
|
||||
pass
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
텔레그램 봇 폴링 서비스
|
||||
- 백그라운드에서 텔레그램 봇의 업데이트를 폴링
|
||||
- 인라인 버튼 클릭 처리 (가입 승인/거부)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from telegram import Update
|
||||
from telegram.ext import Application, CallbackQueryHandler, ContextTypes
|
||||
from flask import Flask
|
||||
|
||||
from backend.models.telegram_bot import TelegramBot
|
||||
from backend.models.user import User, db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_approval_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""
|
||||
텔레그램 인라인 버튼 클릭 처리
|
||||
callback_data 형식: "approve_{token}" 또는 "reject_{token}"
|
||||
"""
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
data = query.data or ""
|
||||
logger.info("Received callback: %s", data)
|
||||
|
||||
# Flask app 객체는 bot_data에 저장해둔 것을 사용
|
||||
flask_app: Optional[Flask] = context.application.bot_data.get("flask_app")
|
||||
|
||||
if flask_app is None:
|
||||
logger.error("Flask app context is missing in bot_data")
|
||||
await query.edit_message_text(
|
||||
text="❌ 내부 설정 오류로 요청을 처리할 수 없습니다. 관리자에게 문의해 주세요."
|
||||
)
|
||||
return
|
||||
|
||||
# callback_data 형식 검증
|
||||
if "_" not in data:
|
||||
logger.warning("Invalid callback data format: %s", data)
|
||||
await query.edit_message_text(
|
||||
text="❌ 유효하지 않은 요청입니다."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
action, token = data.split("_", 1)
|
||||
except ValueError:
|
||||
logger.warning("Failed to split callback data: %s", data)
|
||||
await query.edit_message_text(
|
||||
text="❌ 유효하지 않은 요청입니다."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
with flask_app.app_context():
|
||||
# 토큰으로 사용자 찾기
|
||||
user = User.query.filter_by(approval_token=token).first()
|
||||
|
||||
if not user:
|
||||
await query.edit_message_text(
|
||||
text="❌ 유효하지 않은 승인 요청입니다.\n(이미 처리되었거나 만료된 요청)"
|
||||
)
|
||||
return
|
||||
|
||||
if action == "approve":
|
||||
# 승인 처리
|
||||
user.is_approved = True
|
||||
user.is_active = True
|
||||
user.approval_token = None # 토큰 무효화
|
||||
|
||||
db.session.commit()
|
||||
|
||||
await query.edit_message_text(
|
||||
text=(
|
||||
"✅ 승인 완료!\n\n"
|
||||
f"👤 사용자: {user.username}\n"
|
||||
f"📧 이메일: {user.email}\n\n"
|
||||
"사용자가 이제 로그인할 수 있습니다."
|
||||
)
|
||||
)
|
||||
logger.info("User %s approved", user.username)
|
||||
|
||||
elif action == "reject":
|
||||
# 거부 처리 - 사용자 삭제
|
||||
username = user.username
|
||||
email = user.email
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
await query.edit_message_text(
|
||||
text=(
|
||||
"❌ 가입 거부됨\n\n"
|
||||
f"👤 사용자: {username}\n"
|
||||
f"📧 이메일: {email}\n\n"
|
||||
"계정이 삭제되었습니다."
|
||||
)
|
||||
)
|
||||
logger.info("User %s rejected and deleted", username)
|
||||
else:
|
||||
logger.warning("Unknown action in callback: %s", action)
|
||||
await query.edit_message_text(
|
||||
text="❌ 유효하지 않은 요청입니다."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error handling callback: %s", e)
|
||||
# 예외 내용은 사용자에게 직접 노출하지 않음
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
logger.exception("DB rollback failed")
|
||||
|
||||
await query.edit_message_text(
|
||||
text="❌ 요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도하거나 관리자에게 문의해 주세요."
|
||||
)
|
||||
|
||||
|
||||
def run_polling(flask_app: Flask) -> None:
|
||||
"""
|
||||
동기 함수: 백그라운드 스레드에서 직접 호출됨
|
||||
Application.run_polling() 이 내부에서 asyncio 이벤트 루프를 관리하므로
|
||||
여기서는 asyncio.run 을 사용하지 않는다.
|
||||
"""
|
||||
if flask_app is None:
|
||||
raise ValueError("flask_app is required for run_polling")
|
||||
|
||||
bot_token: Optional[str] = None
|
||||
bot_name: Optional[str] = None
|
||||
bot_id: Optional[int] = None
|
||||
|
||||
# DB에서 활성 봇 조회
|
||||
with flask_app.app_context():
|
||||
bots = TelegramBot.query.filter_by(is_active=True).all()
|
||||
|
||||
if not bots:
|
||||
logger.warning("No active bots found for polling service.")
|
||||
return
|
||||
|
||||
if len(bots) > 1:
|
||||
logger.warning("Multiple active bots found. Only the first one (%s) will be used.", bots[0].name)
|
||||
|
||||
# 첫 번째 활성 봇 사용
|
||||
bot = bots[0]
|
||||
# DB 세션 밖에서도 사용할 수 있도록 필요한 정보만 추출 (Detached Instance 에러 방지)
|
||||
bot_token = bot.token
|
||||
bot_name = bot.name
|
||||
bot_id = bot.id
|
||||
|
||||
logger.info("Starting polling for bot: %s (ID: %s)", bot_name, bot_id)
|
||||
|
||||
if not bot_token:
|
||||
logger.error("Bot token not found.")
|
||||
return
|
||||
|
||||
# Application 생성
|
||||
application = Application.builder().token(bot_token).build()
|
||||
|
||||
# Flask app을 bot_data에 넣어서 핸들러에서 사용할 수 있게 함
|
||||
application.bot_data["flask_app"] = flask_app
|
||||
|
||||
# 콜백 쿼리 핸들러 등록
|
||||
application.add_handler(CallbackQueryHandler(handle_approval_callback))
|
||||
|
||||
try:
|
||||
# v20 스타일: run_polling 은 동기 함수이고, 내부에서 이벤트 루프를 직접 관리함
|
||||
application.run_polling(drop_pending_updates=True, stop_signals=[])
|
||||
except Exception as e:
|
||||
logger.exception("Error in bot polling: %s", e)
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
# 외부에서 주입되는 socketio 인스턴스 (app.py에서 설정)
|
||||
socketio: Optional[SocketIO] = None
|
||||
|
||||
|
||||
class FileCreatedHandler(FileSystemEventHandler):
|
||||
"""파일 생성 감지를 처리하는 Watchdog 핸들러.
|
||||
- temp_ip 등 임시 파일은 무시
|
||||
- 감지 시 진행률/로그를 SocketIO로 실시간 브로드캐스트
|
||||
"""
|
||||
|
||||
def __init__(self, job_id: str, total_files: int):
|
||||
super().__init__()
|
||||
self.job_id = job_id
|
||||
self.total_files = max(int(total_files or 0), 0)
|
||||
self.completed_files = 0
|
||||
|
||||
def _broadcast(self, event_name: str, data: dict) -> None:
|
||||
if not socketio:
|
||||
return
|
||||
try:
|
||||
socketio.emit(event_name, data, namespace="/")
|
||||
except Exception as e:
|
||||
logging.warning("[Watchdog] SocketIO 전송 실패: %s", e)
|
||||
|
||||
def _should_ignore(self, src_path: str) -> bool:
|
||||
# 임시 업로드 디렉터리 하위 파일은 무시
|
||||
return "temp_ip" in src_path.replace("\\", "/")
|
||||
|
||||
def on_created(self, event):
|
||||
if event.is_directory:
|
||||
return
|
||||
if self._should_ignore(event.src_path):
|
||||
return
|
||||
|
||||
self.completed_files = min(self.completed_files + 1, self.total_files or 0)
|
||||
filename = os.path.basename(event.src_path)
|
||||
msg = f"[Watchdog] 생성된 파일: {filename} ({self.completed_files}/{self.total_files})"
|
||||
logging.info(msg)
|
||||
|
||||
self._broadcast("log_update", {"job_id": self.job_id, "log": msg})
|
||||
if self.total_files:
|
||||
progress = int((self.completed_files / self.total_files) * 100)
|
||||
self._broadcast("progress", {"job_id": self.job_id, "progress": progress})
|
||||
@@ -0,0 +1,13 @@
|
||||
# backend/socketio_events.py
|
||||
import logging
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
def register_socketio_events(socketio: SocketIO):
|
||||
@socketio.on('connect')
|
||||
def on_connect():
|
||||
logging.info("✅ 클라이언트가 연결되었습니다.")
|
||||
socketio.emit('response', {'message': '✅ 서버에 연결되었습니다.'})
|
||||
|
||||
@socketio.on('disconnect')
|
||||
def on_disconnect():
|
||||
logging.info("⚠️ 클라이언트 연결이 해제되었습니다.")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1,76 @@
|
||||
/* Hover and Transition Effects */
|
||||
.hover-shadow:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15) !important;
|
||||
}
|
||||
|
||||
.transition-all {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Text Truncation */
|
||||
.text-truncate-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* TSR Share URL */
|
||||
.tsr-share-url-box {
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(13, 110, 253, 0.18);
|
||||
border-radius: 0.75rem;
|
||||
background: linear-gradient(180deg, rgba(13, 110, 253, 0.04), rgba(248, 249, 250, 0.9));
|
||||
}
|
||||
|
||||
.tsr-share-url-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border: 1px solid rgba(13, 110, 253, 0.18);
|
||||
border-radius: 999px;
|
||||
background-color: #fff;
|
||||
color: #0d6efd;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tsr-share-url-input .input-group-text {
|
||||
border-color: rgba(13, 110, 253, 0.24);
|
||||
background-color: #fff;
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.tsr-share-url-input .form-control {
|
||||
border-color: rgba(13, 110, 253, 0.24);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tsr-share-url-input .form-control:focus {
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.12);
|
||||
}
|
||||
|
||||
.tsr-share-url-help {
|
||||
color: #6c757d;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.tsr-share-url-box {
|
||||
padding: 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.tsr-share-url-input {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.tsr-share-url-input .form-control {
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Scrollbar Styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #888;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #888 #f1f1f1;
|
||||
}
|
||||
|
||||
/* Textarea Styles */
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #ccc;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
textarea:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
/* XML List Item Styles */
|
||||
.xml-list-item {
|
||||
padding: 10px;
|
||||
background-color: #ffffff;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.xml-list-item:hover {
|
||||
background-color: #e9ecef;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn {
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
/**
|
||||
* Dell iDRAC 멀티 서버 펌웨어 관리 스타일
|
||||
* backend/static/css/idrac_style.css
|
||||
*/
|
||||
|
||||
/* 기본 스타일 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
header {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
color: #333;
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
header .subtitle {
|
||||
color: #666;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
/* 카드 */
|
||||
.card {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
color: #333;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 필터 그룹 */
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 8px 15px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 서버 테이블 */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.server-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.server-table thead {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.server-table th {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.server-table tbody tr {
|
||||
border-bottom: 1px solid #eee;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.server-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.server-table td {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 상태 배지 */
|
||||
.status {
|
||||
padding: 5px 12px;
|
||||
border-radius: 15px;
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-registered {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.status-updating {
|
||||
background: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: #f0f0f0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 버튼 */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
background: #138496;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* 일괄 작업 */
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.bulk-actions span {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 30px;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
color: #333;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.close {
|
||||
font-size: 2em;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 20px 30px;
|
||||
border-top: 2px solid #eee;
|
||||
}
|
||||
|
||||
/* 폼 그룹 */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
/* 진행 상황 */
|
||||
.progress-item {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.progress-status {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
transition: width 0.3s, background 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.progress-uploading {
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.progress-completed {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.progress-failed {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-message {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.progress-summary {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-stats span {
|
||||
padding: 5px 15px;
|
||||
background: white;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* 결과 테이블 */
|
||||
.result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.result-table thead {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.result-table th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.result-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.result-table tr.success {
|
||||
background: #e8f5e9;
|
||||
}
|
||||
|
||||
.result-table tr.failed {
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
.result-badge {
|
||||
padding: 5px 10px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-failed {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 메시지 */
|
||||
.info-text {
|
||||
padding: 15px;
|
||||
background: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
border-radius: 5px;
|
||||
color: #1976d2;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
padding: 15px;
|
||||
background: #fff3e0;
|
||||
border-left: 4px solid #ff9800;
|
||||
border-radius: 5px;
|
||||
color: #f57c00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 체크박스 커스텀 */
|
||||
input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
탭 메뉴 스타일
|
||||
======================================== */
|
||||
|
||||
.tab-menu {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
border: 2px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.tab-button:hover:not(.active) {
|
||||
background: #f8f9fa;
|
||||
border-color: #667eea;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
토스트 알림 스타일
|
||||
======================================== */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 15px 25px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
|
||||
transform: translateX(400px);
|
||||
transition: transform 0.3s ease-out;
|
||||
z-index: 2000;
|
||||
max-width: 400px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background: linear-gradient(135deg, #ffc107 0%, #ff9800 100%);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: linear-gradient(135deg, #17a2b8 0%, #138496 100%);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
로딩 스피너
|
||||
======================================== */
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(102, 126, 234, 0.2);
|
||||
border-radius: 50%;
|
||||
border-top-color: #667eea;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#comparison-loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
#comparison-loading p {
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
비교 결과 개선 스타일
|
||||
======================================== */
|
||||
|
||||
.comparison-grid {
|
||||
display: grid;
|
||||
gap: 30px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.server-comparison-section {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.server-comparison-section h3 {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.comparison-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid #667eea;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 15px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.comparison-card:hover {
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.comparison-card.outdated {
|
||||
border-left-color: #dc3545;
|
||||
background: linear-gradient(to right, #fff5f5 0%, white 100%);
|
||||
}
|
||||
|
||||
.comparison-card.latest {
|
||||
border-left-color: #28a745;
|
||||
background: linear-gradient(to right, #f0fff4 0%, white 100%);
|
||||
}
|
||||
|
||||
.comparison-card.unknown {
|
||||
border-left-color: #ffc107;
|
||||
background: linear-gradient(to right, #fffbf0 0%, white 100%);
|
||||
}
|
||||
|
||||
.comparison-card code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
반응형 개선
|
||||
======================================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tab-menu {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toast {
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.comparison-card {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* Status Dot Styles */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background-color: #198754;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table Text Handling */
|
||||
#jobs-table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#jobs-table td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Column Width Fixed */
|
||||
#jobs-table td:nth-child(1) {
|
||||
max-width: 110px;
|
||||
}
|
||||
|
||||
/* IP */
|
||||
#jobs-table td:nth-child(2) {
|
||||
max-width: 160px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* JID */
|
||||
#jobs-table td:nth-child(3) {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* 작업명 */
|
||||
#jobs-table td:nth-child(4) {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
/* 상태 */
|
||||
#jobs-table td:nth-child(5) {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* 진행률 */
|
||||
#jobs-table td:nth-child(6) {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* 메시지 */
|
||||
#jobs-table td:nth-child(7) {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
/* 시간 */
|
||||
|
||||
/* Hover to Show Full Text */
|
||||
#jobs-table td:hover {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/* Custom styles for XML Management (manage_xml.html) */
|
||||
.main-title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.card-header-custom {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* File Input Styling */
|
||||
.custom-file {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.custom-file-input {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: calc(1.5em + .75rem + 2px);
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.custom-file-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
height: calc(1.5em + .75rem + 2px);
|
||||
padding: .375rem .75rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: .25rem;
|
||||
}
|
||||
|
||||
.custom-file-label::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
height: calc(1.5em + .75rem);
|
||||
padding: .375rem .75rem;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
content: "Browse";
|
||||
background-color: #e9ecef;
|
||||
border-left: inherit;
|
||||
border-radius: 0 .25rem .25rem 0;
|
||||
}
|
||||
|
||||
/* 아이콘 + 뱃지 스타일 */
|
||||
.file-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
padding-right: 5px;
|
||||
/* 스크롤바 공간 확보 */
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.icon-badge-item {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
/* 둥글게 */
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.2s ease-in-out;
|
||||
background: white;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.icon-badge-item:hover {
|
||||
background-color: #f1f8ff;
|
||||
/* 아주 연한 파랑 */
|
||||
border-color: #cce5ff;
|
||||
transform: translateY(-2px);
|
||||
/* 살짝 위로 */
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.icon-badge-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* 텍스트 말줄임 필수 */
|
||||
margin-right: 15px;
|
||||
/* 버튼과 간격 확보 */
|
||||
}
|
||||
|
||||
.select-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-icon-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #007bff, #0056b3);
|
||||
/* 그라데이션 */
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 123, 255, 0.2);
|
||||
}
|
||||
|
||||
.file-name-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 이름과 뱃지를 위아래로 */
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-name-badge {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.badge-custom {
|
||||
background-color: #e7f3ff;
|
||||
color: #007bff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
/* 절대 줄어들지 않음 */
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 버튼 스타일 개선 */
|
||||
.action-buttons .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-buttons .btn i {
|
||||
margin-right: 4px;
|
||||
/* 아이콘과 텍스트 사이 간격 */
|
||||
}
|
||||
|
||||
/* 모바일 대응: 화면이 좁을 땐 텍스트 숨기기 */
|
||||
@media (max-width: 768px) {
|
||||
.action-buttons .btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-buttons .btn i {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.action-buttons .btn {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px;
|
||||
font-size: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #ddd;
|
||||
}
|
||||
|
||||
/* Diff View Styles (scp_diff.html) */
|
||||
.diff-container {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9rem;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.diff-add {
|
||||
background-color: #e6ffec;
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
.diff-del {
|
||||
background-color: #ffebe9;
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
.diff-header {
|
||||
color: #6f42c1;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/* 탭 메뉴 스타일 */
|
||||
.tab-menu {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 12px 25px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: #667eea;
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #667eea;
|
||||
border-bottom-color: #667eea;
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 버전 상태 표시 */
|
||||
.version-status {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.version-status-outdated {
|
||||
background: #fee;
|
||||
color: #dc3545;
|
||||
border: 1px solid #fcc;
|
||||
}
|
||||
|
||||
.version-status-outdated::before {
|
||||
content: "🔴 ";
|
||||
}
|
||||
|
||||
.version-status-latest {
|
||||
background: #efe;
|
||||
color: #28a745;
|
||||
border: 1px solid #cfc;
|
||||
}
|
||||
|
||||
.version-status-latest::before {
|
||||
content: "🟢 ";
|
||||
}
|
||||
|
||||
.version-status-unknown {
|
||||
background: #ffeaa7;
|
||||
color: #e17055;
|
||||
border: 1px solid #fdcb6e;
|
||||
}
|
||||
|
||||
.version-status-unknown::before {
|
||||
content: "🟡 ";
|
||||
}
|
||||
|
||||
/* 버전 비교 결과 카드 */
|
||||
.comparison-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.comparison-card h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.comparison-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-item .label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.summary-item .value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.summary-item.outdated .value {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.summary-item.latest .value {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.summary-item.unknown .value {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
/* 펌웨어 항목 리스트 */
|
||||
.firmware-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.firmware-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.firmware-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.firmware-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.firmware-versions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.version-current {
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.version-arrow {
|
||||
color: #999;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.version-latest {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.version-recommendation {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* 중요 업데이트 배지 */
|
||||
.critical-badge {
|
||||
display: inline-block;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 다운로드 링크 */
|
||||
.download-link {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.download-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 버전 관리 테이블 */
|
||||
.version-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.version-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.version-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.version-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 빈 상태 메시지 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-state .message {
|
||||
font-size: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 필터 그룹 개선 */
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 진행률 표시 */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.tab-menu {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
border-bottom: none;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
border-left-color: #667eea;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.comparison-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.firmware-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.firmware-versions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 579 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,51 @@
|
||||
(function () {
|
||||
// Bootstrap 5을 사용한다고 가정. data-bs-* 이벤트로 처리.
|
||||
const changePasswordModal = document.getElementById('changePasswordModal');
|
||||
const modalUserInfo = document.getElementById('modalUserInfo');
|
||||
const changePasswordForm = document.getElementById('changePasswordForm');
|
||||
const newPasswordInput = document.getElementById('newPasswordInput');
|
||||
const confirmPasswordInput = document.getElementById('confirmPasswordInput');
|
||||
const pwMismatch = document.getElementById('pwMismatch');
|
||||
|
||||
if (!changePasswordModal) return;
|
||||
|
||||
changePasswordModal.addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget; // 버튼 that triggered the modal
|
||||
const userId = button.getAttribute('data-user-id');
|
||||
const username = button.getAttribute('data-username') || ('ID ' + userId);
|
||||
|
||||
// 표시 텍스트 세팅
|
||||
modalUserInfo.textContent = username + ' (ID: ' + userId + ')';
|
||||
|
||||
// 폼 action 동적 설정: admin.reset_password 라우트 기대
|
||||
// 예: /admin/users/123/reset_password
|
||||
// Note: This assumes the URL pattern exists. Adjust if needed.
|
||||
const baseUrl = changePasswordForm.getAttribute('data-base-url') || '/admin/users/0/reset_password';
|
||||
changePasswordForm.action = baseUrl.replace('/0/', '/' + userId + '/');
|
||||
|
||||
// 폼 내부 비밀번호 필드 초기화
|
||||
newPasswordInput.value = '';
|
||||
confirmPasswordInput.value = '';
|
||||
confirmPasswordInput.classList.remove('is-invalid');
|
||||
pwMismatch.style.display = 'none';
|
||||
});
|
||||
|
||||
// 폼 제출 전 클라이언트에서 비밀번호 일치 검사
|
||||
changePasswordForm.addEventListener('submit', function (e) {
|
||||
const a = newPasswordInput.value || '';
|
||||
const b = confirmPasswordInput.value || '';
|
||||
if (a.length < 8) {
|
||||
newPasswordInput.focus();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (a !== b) {
|
||||
e.preventDefault();
|
||||
confirmPasswordInput.classList.add('is-invalid');
|
||||
pwMismatch.style.display = 'block';
|
||||
confirmPasswordInput.focus();
|
||||
return;
|
||||
}
|
||||
// 제출 허용 (서버측에서도 반드시 검증)
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* BIOS Baseline 비교 JavaScript
|
||||
* backend/static/js/bios_baseline.js
|
||||
*/
|
||||
|
||||
// 전역 변수
|
||||
let baselines = [];
|
||||
let comparisonResults = [];
|
||||
let showDiffOnly = true;
|
||||
|
||||
// ========================================
|
||||
// 초기화
|
||||
// ========================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadBaselines();
|
||||
|
||||
// IP 입력 카운트 업데이트
|
||||
document.getElementById('ip-addresses').addEventListener('input', updateIPCount);
|
||||
|
||||
// Baseline 생성 폼 제출
|
||||
document.getElementById('create-baseline-form').addEventListener('submit', handleCreateBaseline);
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Baseline 관리
|
||||
// ========================================
|
||||
|
||||
async function loadBaselines() {
|
||||
try {
|
||||
const res = await fetch('/bios-baseline/api/baselines');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
baselines = data.baselines;
|
||||
renderBaselineSelect();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Baseline 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBaselineSelect() {
|
||||
const select = document.getElementById('baseline-select');
|
||||
select.innerHTML = '<option value="">Baseline을 선택하세요</option>';
|
||||
|
||||
baselines.forEach(baseline => {
|
||||
const option = document.createElement('option');
|
||||
option.value = baseline.id;
|
||||
option.textContent = `${baseline.name} (${baseline.server_model})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function showBaselineManageModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('baseline-manage-modal'));
|
||||
modal.show();
|
||||
loadBaselineList();
|
||||
}
|
||||
|
||||
async function loadBaselineList() {
|
||||
const container = document.getElementById('baseline-list');
|
||||
container.innerHTML = '<div class="text-center text-muted py-4"><i class="bi bi-hourglass-split"></i> 로딩 중...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/bios-baseline/api/baselines');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.baselines.length > 0) {
|
||||
let html = '<div class="list-group">';
|
||||
data.baselines.forEach(baseline => {
|
||||
html += `
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-1">${baseline.name}</h6>
|
||||
<p class="mb-1 text-muted small">
|
||||
<i class="bi bi-server me-1"></i>${baseline.server_model}
|
||||
${baseline.server_type ? `<span class="badge bg-secondary ms-2">${baseline.server_type}</span>` : ''}
|
||||
</p>
|
||||
<p class="mb-0 text-muted small">
|
||||
${baseline.description || '설명 없음'}
|
||||
</p>
|
||||
<p class="mb-0 text-muted small">
|
||||
<i class="bi bi-gear me-1"></i>${baseline.total_settings}개 설정
|
||||
<i class="bi bi-calendar ms-2 me-1"></i>${new Date(baseline.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="btn-group-vertical btn-group-sm">
|
||||
<button class="btn btn-outline-secondary" onclick="editBaseline(${baseline.id})">
|
||||
<i class="bi bi-pencil"></i> 편집
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" onclick="duplicateBaseline(${baseline.id})">
|
||||
<i class="bi bi-files"></i> 복사
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" onclick="deleteBaseline(${baseline.id})">
|
||||
<i class="bi bi-trash"></i> 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
} else {
|
||||
container.innerHTML = '<div class="text-center text-muted py-4">등록된 Baseline이 없습니다</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="text-center text-danger py-4">로드 실패</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateBaseline(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
ip_address: document.getElementById('create-ip').value,
|
||||
username: document.getElementById('create-username').value,
|
||||
password: document.getElementById('create-password').value,
|
||||
name: document.getElementById('create-name').value,
|
||||
server_type: document.getElementById('create-type').value,
|
||||
description: document.getElementById('create-description').value,
|
||||
created_by: 'admin'
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/bios-baseline/api/baselines/create-from-server', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('✓ Baseline 생성 완료!');
|
||||
document.getElementById('create-baseline-form').reset();
|
||||
loadBaselines();
|
||||
loadBaselineList();
|
||||
} else {
|
||||
alert('✗ 생성 실패: ' + result.message);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('✗ 오류: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateBaseline(baselineId) {
|
||||
const newName = prompt('새 Baseline 이름을 입력하세요:');
|
||||
if (!newName) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/bios-baseline/api/baselines/${baselineId}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_name: newName })
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('✓ Baseline 복사 완료!');
|
||||
loadBaselines();
|
||||
loadBaselineList();
|
||||
} else {
|
||||
alert('✗ 복사 실패: ' + result.message);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('✗ 오류: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Baseline 삭제
|
||||
async function deleteBaseline(baselineId) {
|
||||
if (!confirm('정말 이 Baseline을 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/bios-baseline/api/baselines/${baselineId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('✓ Baseline 삭제 완료!');
|
||||
loadBaselines();
|
||||
loadBaselineList();
|
||||
} else {
|
||||
alert('✗ 삭제 실패: ' + result.message);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('✗ 오류: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Baseline 상세 편집
|
||||
// ========================================
|
||||
|
||||
async function editBaseline(baselineId) {
|
||||
try {
|
||||
const res = await fetch(`/bios-baseline/api/baselines/${baselineId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
const baseline = data.baseline;
|
||||
currentBaselineId = baselineId; // 전역 변수 설정
|
||||
|
||||
// 메타데이터 채우기
|
||||
document.getElementById('edit-id').value = baseline.id;
|
||||
document.getElementById('edit-name').value = baseline.name;
|
||||
document.getElementById('edit-model').value = baseline.server_model;
|
||||
document.getElementById('edit-type').value = baseline.server_type || '';
|
||||
document.getElementById('edit-description').value = baseline.description || '';
|
||||
document.getElementById('edit-created-at').textContent = baseline.created_at || '-';
|
||||
document.getElementById('edit-source-ip').textContent = baseline.source_ip || '-';
|
||||
|
||||
// BIOS, iDRAC, Firmware Table 렌더링
|
||||
renderEditTable('bios', baseline.bios_data || {});
|
||||
renderEditTable('idrac', baseline.idrac_data || {});
|
||||
renderEditTable('firmware', baseline.firmware_data || {});
|
||||
|
||||
// RAID, NIC, Boot는 JSON Textarea에 표시
|
||||
document.getElementById('json-edit-raid').value = JSON.stringify(baseline.raid_data || {}, null, 4);
|
||||
document.getElementById('json-edit-nic').value = JSON.stringify(baseline.nic_data || {}, null, 4);
|
||||
document.getElementById('json-edit-boot').value = JSON.stringify(baseline.boot_data || {}, null, 4);
|
||||
|
||||
// 모달 표시
|
||||
const modal = new bootstrap.Modal(document.getElementById('edit-baseline-modal'));
|
||||
modal.show();
|
||||
|
||||
// 첫 번째 탭(BIOS) 활성화
|
||||
const firstTab = new bootstrap.Tab(document.querySelector('#tab-edit-bios'));
|
||||
firstTab.show();
|
||||
} else {
|
||||
alert('로드 실패: ' + data.message);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('오류: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderEditTable(type, settings) {
|
||||
const tbody = document.querySelector(`#table-edit-${type} tbody`);
|
||||
tbody.innerHTML = '';
|
||||
|
||||
const entries = Object.entries(settings).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
document.getElementById(`edit-${type}-count`).textContent = entries.length;
|
||||
|
||||
entries.forEach(([key, value]) => {
|
||||
addSettingRow(type, key, value);
|
||||
});
|
||||
}
|
||||
|
||||
function addSettingRow(type, key = '', value = '') {
|
||||
const tbody = document.querySelector(`#table-edit-${type} tbody`);
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
tr.innerHTML = `
|
||||
<td><input type="text" class="form-control form-control-sm setting-key" value="${key}" placeholder="Key"></td>
|
||||
<td><input type="text" class="form-control form-control-sm setting-value" value="${value}" placeholder="Value"></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="removeSettingRow(this, '${type}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
// 새 행은 맨 위에 추가 (비어있을 경우) 혹은 맨 아래
|
||||
if (!key) {
|
||||
tbody.prepend(tr);
|
||||
tr.querySelector('.setting-key').focus();
|
||||
} else {
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
function removeSettingRow(btn, type) {
|
||||
btn.closest('tr').remove();
|
||||
updateSettingsCount(type);
|
||||
}
|
||||
|
||||
function updateSettingsCount(type) {
|
||||
const count = document.querySelectorAll(`#table-edit-${type} tbody tr`).length;
|
||||
document.getElementById(`edit-${type}-count`).textContent = count;
|
||||
}
|
||||
|
||||
async function saveBaselineChanges() {
|
||||
const id = document.getElementById('edit-id').value;
|
||||
const name = document.getElementById('edit-name').value;
|
||||
|
||||
if (!name) {
|
||||
alert('Baseline 이름을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper function to collect table data
|
||||
const collectTableData = (type) => {
|
||||
const data = {};
|
||||
const rows = document.querySelectorAll(`#table-edit-${type} tbody tr`);
|
||||
rows.forEach(row => {
|
||||
const key = row.querySelector('.setting-key').value.trim();
|
||||
const value = row.querySelector('.setting-value').value.trim();
|
||||
if (key) data[key] = value;
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
const biosData = collectTableData('bios');
|
||||
const idracData = collectTableData('idrac');
|
||||
const firmwareData = collectTableData('firmware');
|
||||
|
||||
// JSON Data Parsing
|
||||
let raidData = {};
|
||||
let nicData = {};
|
||||
let bootData = {};
|
||||
|
||||
try {
|
||||
raidData = JSON.parse(document.getElementById('json-edit-raid').value || '{}');
|
||||
} catch (e) {
|
||||
alert('RAID 구성 JSON 형식이 올바르지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
nicData = JSON.parse(document.getElementById('json-edit-nic').value || '{}');
|
||||
} catch (e) {
|
||||
alert('NIC 설정 JSON 형식이 올바르지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
bootData = JSON.parse(document.getElementById('json-edit-boot').value || '{}');
|
||||
} catch (e) {
|
||||
alert('부팅 설정 JSON 형식이 올바르지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: name,
|
||||
server_type: document.getElementById('edit-type').value,
|
||||
description: document.getElementById('edit-description').value,
|
||||
bios_data: biosData,
|
||||
idrac_data: idracData,
|
||||
raid_data: raidData,
|
||||
nic_data: nicData,
|
||||
boot_data: bootData,
|
||||
firmware_data: firmwareData
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`/bios-baseline/api/baselines/${currentBaselineId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('✓ 변경사항이 저장되었습니다!');
|
||||
const modalEl = document.getElementById('edit-baseline-modal');
|
||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||
modal.hide();
|
||||
loadBaselines();
|
||||
loadBaselineList();
|
||||
} else {
|
||||
alert('✗ 저장 실패: ' + result.message);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('✗ 오류: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 비교 기능
|
||||
// ========================================
|
||||
|
||||
async function startComparison() {
|
||||
const baselineId = document.getElementById('baseline-select').value;
|
||||
const ipText = document.getElementById('ip-addresses').value;
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!baselineId) {
|
||||
alert('Baseline을 선택하세요');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ipText.trim()) {
|
||||
alert('서버 IP를 입력하세요');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
alert('비밀번호를 입력하세요');
|
||||
return;
|
||||
}
|
||||
|
||||
// IP 파싱
|
||||
const ipAddresses = ipText.split(/[\n,\s]+/).filter(ip => ip.trim());
|
||||
|
||||
if (ipAddresses.length === 0) {
|
||||
alert('유효한 IP 주소가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 진행률 표시
|
||||
document.getElementById('progress-section').style.display = 'block';
|
||||
document.getElementById('results-section').style.display = 'none';
|
||||
updateProgress(0, ipAddresses.length);
|
||||
|
||||
try {
|
||||
const res = await fetch('/bios-baseline/api/compare', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ip_addresses: ipAddresses,
|
||||
baseline_id: parseInt(baselineId),
|
||||
username: username,
|
||||
password: password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
comparisonResults = data.results;
|
||||
|
||||
// Toggle 버튼 초기 상태 설정
|
||||
const toggleBtn = document.getElementById('diff-only-btn');
|
||||
if (showDiffOnly) {
|
||||
toggleBtn.classList.add('active');
|
||||
} else {
|
||||
toggleBtn.classList.remove('active');
|
||||
}
|
||||
|
||||
renderSummary(data.baseline, data.results);
|
||||
renderResults(data.results);
|
||||
document.getElementById('results-section').style.display = 'block';
|
||||
} else {
|
||||
alert('비교 실패: ' + data.message);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('오류: ' + e.message);
|
||||
} finally {
|
||||
document.getElementById('progress-section').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress(current, total) {
|
||||
const percent = Math.round((current / total) * 100);
|
||||
const bar = document.getElementById('progress-bar');
|
||||
bar.style.width = percent + '%';
|
||||
bar.querySelector('span').textContent = percent + '%';
|
||||
}
|
||||
|
||||
function renderSummary(baseline, results) {
|
||||
const successResults = results.filter(r => r.status === 'success');
|
||||
const totalServers = results.length;
|
||||
const successCount = successResults.length;
|
||||
const failedCount = totalServers - successCount;
|
||||
|
||||
let totalMatched = 0;
|
||||
let totalMismatched = 0;
|
||||
let perfectMatchCount = 0;
|
||||
|
||||
successResults.forEach(r => {
|
||||
totalMatched += r.matched_items;
|
||||
totalMismatched += r.mismatched_items;
|
||||
if (r.mismatched_items === 0) perfectMatchCount++;
|
||||
});
|
||||
|
||||
const avgMatchRate = successCount > 0
|
||||
? Math.round((totalMatched / (totalMatched + totalMismatched)) * 100)
|
||||
: 0;
|
||||
|
||||
const html = `
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-primary">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-server fs-1 text-primary"></i>
|
||||
<h3 class="mt-2 mb-0">${totalServers}</h3>
|
||||
<p class="text-muted small mb-0">총 서버</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-success">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-check-circle fs-1 text-success"></i>
|
||||
<h3 class="mt-2 mb-0">${avgMatchRate}%</h3>
|
||||
<p class="text-muted small mb-0">평균 일치율</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-warning">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-exclamation-triangle fs-1 text-warning"></i>
|
||||
<h3 class="mt-2 mb-0">${totalMismatched}</h3>
|
||||
<p class="text-muted small mb-0">불일치 항목</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-info">
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-patch-check fs-1 text-info"></i>
|
||||
<h3 class="mt-2 mb-0">${perfectMatchCount}</h3>
|
||||
<p class="text-muted small mb-0">완전 일치</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 p-3 bg-light rounded">
|
||||
<p class="mb-0 small">
|
||||
<strong>Baseline:</strong> ${baseline.name} (${baseline.server_model})
|
||||
<span class="ms-3"><strong>총 설정:</strong> ${baseline.total_settings}개</span>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('summary-section').innerHTML = html;
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
const container = document.getElementById('results-container');
|
||||
|
||||
if (results.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted text-center">결과가 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="accordion" id="results-accordion">';
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const statusClass = result.status === 'success'
|
||||
? (result.mismatched_items === 0 ? 'success' : 'warning')
|
||||
: 'danger';
|
||||
const statusIcon = result.status === 'success'
|
||||
? (result.mismatched_items === 0 ? 'check-circle-fill' : 'exclamation-triangle-fill')
|
||||
: 'x-circle-fill';
|
||||
|
||||
html += `
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button ${index > 0 ? 'collapsed' : ''}" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#collapse-${index}">
|
||||
<i class="bi bi-${statusIcon} text-${statusClass} me-2"></i>
|
||||
<strong>${result.ip}</strong>
|
||||
${result.service_tag ? `<span class="badge bg-secondary ms-2">${result.service_tag}</span>` : ''}
|
||||
${result.status === 'success' ? `
|
||||
<span class="ms-auto me-3">
|
||||
<span class="badge bg-${statusClass}">${result.match_rate}% 일치</span>
|
||||
${result.mismatched_items > 0 ? `<span class="badge bg-danger ms-1">${result.mismatched_items}개 불일치</span>` : ''}
|
||||
</span>
|
||||
` : ''}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse-${index}" class="accordion-collapse collapse ${index === 0 ? 'show' : ''}"
|
||||
data-bs-parent="#results-accordion">
|
||||
<div class="accordion-body">
|
||||
${renderServerDetail(result)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderServerDetail(result) {
|
||||
if (result.status !== 'success') {
|
||||
return `<div class="alert alert-danger mb-0">${result.message}</div>`;
|
||||
}
|
||||
|
||||
// 통계바
|
||||
let html = `
|
||||
<div class="mb-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><strong>모델:</strong> ${result.server_model}</div>
|
||||
<div class="col-md-4"><strong>Service Tag:</strong> ${result.service_tag}</div>
|
||||
<div class="col-md-4"><strong>BIOS:</strong> ${result.bios_version}</div>
|
||||
</div>
|
||||
<div class="progress mt-2" style="height: 20px;">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: ${result.match_rate}%">
|
||||
${result.match_rate}% 일치
|
||||
</div>
|
||||
<div class="progress-bar bg-danger" role="progressbar" style="width: ${100 - result.match_rate}%">
|
||||
${result.mismatched_items} 불일치
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 탭 헤더 생성 (Nav Pills)
|
||||
const uid = Math.random().toString(36).substr(2, 9); // 유니크 ID
|
||||
html += `
|
||||
<div class="nav-pills-container">
|
||||
<ul class="nav nav-pills mb-0" id="result-tabs-${uid}" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-bios-${uid}" type="button">BIOS</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-idrac-${uid}" type="button">iDRAC</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-raid-${uid}" type="button">RAID</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-nic-${uid}" type="button">NIC</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-boot-${uid}" type="button">Boot</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-firmware-${uid}" type="button">Firmware</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
`;
|
||||
|
||||
// 섹션별 Diff 필터링
|
||||
const diffs = result.differences || [];
|
||||
|
||||
// 탭 콘텐츠 생성 Helper
|
||||
const renderTabPane = (section, id, isActive = false) => {
|
||||
const sectionDiffs = diffs.filter(d => d.section === section);
|
||||
// 섹션 이름이 영어로 저장되어 있다고 가정 (BIOS, iDRAC, RAID, NIC, Boot, Firmware)
|
||||
return `
|
||||
<div class="tab-pane fade ${isActive ? 'show active' : ''}" id="tab-${id}-${uid}" role="tabpanel">
|
||||
${renderDiffTable(sectionDiffs, `${section} 설정이 기준과 일치합니다.`)}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
html += renderTabPane('BIOS', 'bios', true);
|
||||
html += renderTabPane('iDRAC', 'idrac');
|
||||
html += renderTabPane('RAID', 'raid');
|
||||
html += renderTabPane('NIC', 'nic');
|
||||
html += renderTabPane('Boot', 'boot');
|
||||
html += renderTabPane('Firmware', 'firmware');
|
||||
|
||||
html += `</div>`; // tab-content end
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderDiffTable(diffs, emptyMsg) {
|
||||
if (diffs.length === 0) {
|
||||
return `<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-1"></i> ${emptyMsg}</div>`;
|
||||
}
|
||||
|
||||
if (showDiffOnly && diffs.every(d => d.status !== 'mismatch')) {
|
||||
return `<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-1"></i> ${emptyMsg} (필터링됨)</div>`;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<table class="table table-sm table-bordered table-hover" style="table-layout: fixed;">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th width="40%">항목</th>
|
||||
<th width="30%">기준값 (Baseline)</th>
|
||||
<th width="30%">실제값 (Server)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
diffs.forEach(diff => {
|
||||
const isMismatch = diff.status === 'mismatch';
|
||||
const rowClass = isMismatch ? 'table-danger' : '';
|
||||
|
||||
if (!showDiffOnly || isMismatch) {
|
||||
html += `
|
||||
<tr class="${rowClass}">
|
||||
<td style="word-wrap: break-word;">
|
||||
${diff.display_name ? `<strong>${diff.display_name}</strong><br><small class="text-muted" style="word-break: break-all;">${diff.setting_name}</small>` : `<code style="word-break: break-all;">${diff.setting_name}</code>`}
|
||||
</td>
|
||||
<td style="word-break: break-all;">${diff.expected}</td>
|
||||
<td style="word-break: break-all;"><span class="${isMismatch ? '' : 'text-success'}">${diff.actual}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function toggleDiffOnly() {
|
||||
showDiffOnly = !showDiffOnly;
|
||||
const btn = document.getElementById('diff-only-btn');
|
||||
btn.classList.toggle('active');
|
||||
renderResults(comparisonResults);
|
||||
}
|
||||
|
||||
function downloadResults() {
|
||||
alert('Excel 다운로드 기능은 곧 추가될 예정입니다.');
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 유틸리티
|
||||
// ========================================
|
||||
|
||||
function updateIPCount() {
|
||||
const ipText = document.getElementById('ip-addresses').value;
|
||||
const ips = ipText.split(/[\n,\s]+/).filter(ip => ip.trim());
|
||||
document.getElementById('ip-count').textContent = ips.length;
|
||||
}
|
||||
|
||||
function clearIPs() {
|
||||
document.getElementById('ip-addresses').value = '';
|
||||
updateIPCount();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Dell iDRAC 멀티 서버 관리 JavaScript
|
||||
* backend/static/js/idrac_main.js
|
||||
*/
|
||||
|
||||
// SocketIO 연결 (혹시 모를 추후 사용을 위해 유지하나, 현재는 직접적 이벤트 없음)
|
||||
const socket = io();
|
||||
|
||||
// 전역 변수
|
||||
let servers = [];
|
||||
let selectedServers = new Set();
|
||||
|
||||
// ========================================
|
||||
// 유틸리티 (CSRF, Fetch, Toast)
|
||||
// ========================================
|
||||
|
||||
function getCSRFToken() {
|
||||
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
||||
if (metaTag) return metaTag.getAttribute('content');
|
||||
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === 'csrf_token') return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchWithCSRF(url, options = {}) {
|
||||
const csrfToken = getCSRFToken();
|
||||
if (!options.headers) options.headers = {};
|
||||
|
||||
if (csrfToken) {
|
||||
options.headers['X-CSRFToken'] = csrfToken;
|
||||
options.headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
if (options.body) {
|
||||
if (options.body instanceof FormData) {
|
||||
// 자동 처리
|
||||
} else if (typeof options.body === 'string') {
|
||||
if (!options.headers['Content-Type']) {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
} else if (typeof options.body === 'object') {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
options.body = JSON.stringify(options.body);
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
function showMessage(message, type = 'info') {
|
||||
showToast(message, type);
|
||||
console.log(`[${type}] ${message}`);
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const existingToast = document.querySelector('.toast');
|
||||
if (existingToast) {
|
||||
existingToast.remove();
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.classList.add('show'), 100);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 초기화
|
||||
// ========================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
console.log('iDRAC 멀티 서버 관리 시스템 시작');
|
||||
refreshServers();
|
||||
loadGroups();
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// 서버 관리 (목록, 렌더링)
|
||||
// ========================================
|
||||
|
||||
async function refreshServers() {
|
||||
try {
|
||||
const groupEl = document.getElementById('group-filter');
|
||||
const group = groupEl ? groupEl.value : 'all';
|
||||
const url = `/idrac/api/servers${group !== 'all' ? '?group=' + group : ''}`;
|
||||
const response = await fetchWithCSRF(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
servers = data.servers;
|
||||
renderServerList();
|
||||
updateServerCount();
|
||||
} else {
|
||||
showMessage(data.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('서버 목록 로드 실패: ' + error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderServerList() {
|
||||
const tbody = document.getElementById('server-list');
|
||||
if (!tbody) return;
|
||||
|
||||
if (servers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="empty-message">등록된 서버가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = servers.map(server => `
|
||||
<tr data-server-id="${server.id}">
|
||||
<td><input type="checkbox" class="server-checkbox" value="${server.id}"
|
||||
${selectedServers.has(server.id) ? 'checked' : ''}
|
||||
onchange="toggleServerSelection(${server.id})"></td>
|
||||
<td><strong>${server.name}</strong></td>
|
||||
<td><code>${server.ip_address}</code></td>
|
||||
<td><span class="badge">${server.group_name || '-'}</span></td>
|
||||
<td><span class="status status-${server.status}">${getStatusText(server.status)}</span></td>
|
||||
<td>${server.model || '-'}</td>
|
||||
<td>${server.last_connected ? formatDateTime(server.last_connected) : '-'}</td>
|
||||
<td class="action-buttons">
|
||||
<button onclick="testConnection(${server.id})" class="btn-icon" title="연결 테스트">🔌</button>
|
||||
<button onclick="editServer(${server.id})" class="btn-icon" title="수정">✏️</button>
|
||||
<button onclick="deleteServer(${server.id})" class="btn-icon" title="삭제">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
const map = { registered: '등록됨', online: '온라인', offline: '오프라인', updating: '업데이트중' };
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: '2-digit', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function updateServerCount() {
|
||||
const el = document.getElementById('server-count');
|
||||
if (el) el.textContent = `${servers.length}대`;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 서버 선택 관리
|
||||
// ========================================
|
||||
|
||||
function toggleSelectAll() {
|
||||
const checkbox = document.getElementById('select-all');
|
||||
const checkboxes = document.querySelectorAll('.server-checkbox');
|
||||
selectedServers.clear();
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
if (checkbox.checked) selectedServers.add(parseInt(cb.value));
|
||||
});
|
||||
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function toggleServerSelection(serverId) {
|
||||
if (selectedServers.has(serverId)) selectedServers.delete(serverId);
|
||||
else selectedServers.add(serverId);
|
||||
updateSelectedCount();
|
||||
|
||||
const all = document.querySelectorAll('.server-checkbox');
|
||||
const checked = document.querySelectorAll('.server-checkbox:checked');
|
||||
const selectAll = document.getElementById('select-all');
|
||||
if (selectAll) selectAll.checked = all.length > 0 && all.length === checked.length;
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
const el = document.getElementById('selected-count');
|
||||
if (el) el.textContent = `선택: ${selectedServers.size}대`;
|
||||
}
|
||||
|
||||
function getSelectedServerIds() {
|
||||
return Array.from(selectedServers);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 그룹 관리
|
||||
// ========================================
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const response = await fetchWithCSRF('/idrac/api/groups');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const select = document.getElementById('group-filter');
|
||||
if (!select) return;
|
||||
|
||||
const currentValue = select.value;
|
||||
select.innerHTML = '<option value="all">전체</option>';
|
||||
data.groups.forEach(g => select.innerHTML += `<option value="${g}">${g}</option>`);
|
||||
if (currentValue) select.value = currentValue;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('그룹 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function filterByGroup() {
|
||||
refreshServers();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 서버 CRUD
|
||||
// ========================================
|
||||
|
||||
function showAddServerModal() {
|
||||
document.getElementById('add-server-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeAddServerModal() {
|
||||
const modal = document.getElementById('add-server-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
setTimeout(() => clearServerForm(), 0);
|
||||
}
|
||||
|
||||
function clearServerForm() {
|
||||
const fieldIds = [
|
||||
'server-name', 'server-ip', 'server-username',
|
||||
'server-password', 'server-group', 'server-model'
|
||||
];
|
||||
fieldIds.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
if (el.type === 'checkbox') el.checked = false;
|
||||
else if (el.type === 'file') el.value = null;
|
||||
else el.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function editServer(serverId) {
|
||||
showMessage('서버 수정 기능은 현재 준비 중입니다.', 'info');
|
||||
// 추후 구현: 모달에 데이터 로드 후 띄우기
|
||||
}
|
||||
|
||||
async function addServer() {
|
||||
const serverData = {
|
||||
name: document.getElementById('server-name').value,
|
||||
ip_address: document.getElementById('server-ip').value,
|
||||
username: document.getElementById('server-username').value,
|
||||
password: document.getElementById('server-password').value,
|
||||
group_name: document.getElementById('server-group').value,
|
||||
model: document.getElementById('server-model').value
|
||||
};
|
||||
|
||||
if (!serverData.name || !serverData.ip_address || !serverData.password) {
|
||||
showMessage('필수 필드를 입력하세요 (서버명, IP, 비밀번호)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithCSRF('/idrac/api/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(serverData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showMessage(data.message, 'success');
|
||||
closeAddServerModal();
|
||||
refreshServers();
|
||||
loadGroups();
|
||||
} else showMessage(data.message, 'error');
|
||||
} catch (e) {
|
||||
showMessage('서버 추가 실패: ' + e, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteServer(serverId) {
|
||||
if (!confirm('이 서버를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const res = await fetchWithCSRF(`/idrac/api/servers/${serverId}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showMessage(data.message, 'success');
|
||||
refreshServers();
|
||||
} else showMessage(data.message, 'error');
|
||||
} catch (e) {
|
||||
showMessage('서버 삭제 실패: ' + e, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 연결 테스트 및 제어
|
||||
// ========================================
|
||||
|
||||
async function testConnection(serverId) {
|
||||
try {
|
||||
const res = await fetchWithCSRF(`/idrac/api/servers/${serverId}/test`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showMessage(data.message, data.success ? 'success' : 'error');
|
||||
refreshServers();
|
||||
} catch (e) {
|
||||
showMessage('연결 테스트 실패: ' + e, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function testSelectedConnections() {
|
||||
const serverIds = getSelectedServerIds();
|
||||
|
||||
if (serverIds.length === 0) {
|
||||
showMessage('서버를 선택하세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage(`선택된 ${serverIds.length}대 서버 연결 테스트 중...`, 'info');
|
||||
|
||||
try {
|
||||
const res = await fetchWithCSRF('/idrac/api/servers/test-multi', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server_ids: serverIds })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const summary = data.summary;
|
||||
showMessage(`연결 성공: ${summary.success}대 / 실패: ${summary.failed}대`, 'success');
|
||||
} else {
|
||||
showMessage(data.message, 'error');
|
||||
}
|
||||
refreshServers();
|
||||
} catch (error) {
|
||||
showMessage('연결 테스트 실패: ' + error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function rebootSelected() {
|
||||
const serverIds = getSelectedServerIds();
|
||||
|
||||
if (serverIds.length === 0) {
|
||||
showMessage('서버를 선택하세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`선택한 ${serverIds.length}대 서버를 재부팅하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithCSRF('/idrac/api/servers/reboot-multi', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
server_ids: serverIds,
|
||||
type: 'GracefulRestart'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const summary = data.summary;
|
||||
showMessage(`재부팅 시작: 성공 ${summary.success}대, 실패 ${summary.failed}대`, 'success');
|
||||
} else {
|
||||
showMessage(data.message, 'error');
|
||||
}
|
||||
|
||||
refreshServers();
|
||||
} catch (error) {
|
||||
showMessage('재부팅 실패: ' + error, 'error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// Note: This script expects csrfToken to be available globally
|
||||
// It should be set in the HTML template before this script loads
|
||||
|
||||
// ========== 전역 변수 ==========
|
||||
let CONFIG = {
|
||||
grace_minutes: 60,
|
||||
recency_hours: 24,
|
||||
poll_interval_ms: 10000
|
||||
};
|
||||
|
||||
let monitoringOn = false;
|
||||
let pollTimer = null;
|
||||
let lastRenderHash = "";
|
||||
|
||||
// ========== Elements ==========
|
||||
const $ = id => document.getElementById(id);
|
||||
const $body = $('jobs-body');
|
||||
const $last = $('last-updated');
|
||||
const $loading = $('loading-indicator');
|
||||
const $btn = $('btn-refresh');
|
||||
const $auto = $('autoRefreshSwitch');
|
||||
const $ipInput = $('ipInput');
|
||||
const $ipCount = $('ip-count');
|
||||
const $btnLoad = $('btn-load-file');
|
||||
const $btnApply = $('btn-apply');
|
||||
const $monSw = $('monitorSwitch');
|
||||
const $statusDot = $('status-dot');
|
||||
const $statusText = $('status-text');
|
||||
const $showCompleted = $('showCompletedSwitch');
|
||||
const $pollInterval = $('poll-interval');
|
||||
|
||||
// Stats
|
||||
const $statTotal = $('stat-total');
|
||||
const $statRunning = $('stat-running');
|
||||
const $statCompleted = $('stat-completed');
|
||||
const $statError = $('stat-error');
|
||||
|
||||
// ========== LocalStorage Keys ==========
|
||||
const LS_IPS = 'idrac_job_ips';
|
||||
const LS_MON = 'idrac_monitor_on';
|
||||
const LS_AUTO = 'idrac_monitor_auto';
|
||||
const LS_SHOW_COMPLETED = 'idrac_show_completed';
|
||||
|
||||
// ========== 유틸리티 ==========
|
||||
function parseIps(text) {
|
||||
if (!text) return [];
|
||||
const raw = text.replace(/[,;]+/g, '\n');
|
||||
const out = [], seen = new Set();
|
||||
raw.split('\n').forEach(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
parts.forEach(p => {
|
||||
if (!p || p.startsWith('#')) return;
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
out.push(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function getIpsFromUI() { return parseIps($ipInput.value); }
|
||||
function setIpsToUI(ips) {
|
||||
$ipInput.value = (ips || []).join('\n');
|
||||
updateIpCount();
|
||||
}
|
||||
function updateIpCount() {
|
||||
const ips = getIpsFromUI();
|
||||
$ipCount.textContent = ips.length;
|
||||
}
|
||||
function saveIps() {
|
||||
localStorage.setItem(LS_IPS, JSON.stringify(getIpsFromUI()));
|
||||
updateIpCount();
|
||||
}
|
||||
function loadIps() {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(LS_IPS) || "[]");
|
||||
return Array.isArray(v) ? v : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, m => ({
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
}[m]));
|
||||
}
|
||||
|
||||
function progressBar(pc) {
|
||||
const n = parseInt(String(pc ?? "").toString().replace('%', '').trim(), 10);
|
||||
if (isNaN(n)) return `<span class="text-muted small">${escapeHtml(pc ?? "")}</span>`;
|
||||
|
||||
let bgClass = 'bg-info';
|
||||
if (n === 100) bgClass = 'bg-success';
|
||||
else if (n < 30) bgClass = 'bg-warning';
|
||||
|
||||
return `<div class="progress" style="height:8px;">
|
||||
<div class="progress-bar ${bgClass}" role="progressbar"
|
||||
style="width:${n}%;" aria-valuenow="${n}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
<small class="text-muted">${n}%</small>`;
|
||||
}
|
||||
|
||||
function badgeStatus(status, pc, recently = false) {
|
||||
const raw = String(status || "");
|
||||
const s = raw.toLowerCase();
|
||||
let cls = "bg-secondary";
|
||||
let icon = "info-circle";
|
||||
|
||||
if (recently) {
|
||||
cls = "bg-success";
|
||||
icon = "check-circle";
|
||||
} else if (s.includes("completed")) {
|
||||
cls = "bg-success";
|
||||
icon = "check-circle";
|
||||
} else if (s.includes("running") || s.includes("progress")) {
|
||||
cls = "bg-info";
|
||||
icon = "arrow-repeat";
|
||||
} else if (s.includes("scheduled") || s.includes("pending")) {
|
||||
cls = "bg-warning text-dark";
|
||||
icon = "clock";
|
||||
} else if (s.includes("failed") || s.includes("error")) {
|
||||
cls = "bg-danger";
|
||||
icon = "x-circle";
|
||||
}
|
||||
|
||||
const pct = parseInt(String(pc ?? "").toString().replace('%', '').trim(), 10);
|
||||
const pctText = isNaN(pct) ? "" : ` (${pct}%)`;
|
||||
const text = recently ? `${raw || "Completed"} (최근${pctText})` : `${raw || "-"}${pctText}`;
|
||||
|
||||
return `<span class="badge ${cls}">
|
||||
<i class="bi bi-${icon}"></i> ${escapeHtml(text)}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function updateStats(items) {
|
||||
let total = 0, running = 0, completed = 0, error = 0;
|
||||
|
||||
items.forEach(it => {
|
||||
if (!it.ok) {
|
||||
error++;
|
||||
return;
|
||||
}
|
||||
if (it.jobs && it.jobs.length) {
|
||||
total++;
|
||||
it.jobs.forEach(j => {
|
||||
const s = (j.Status || "").toLowerCase();
|
||||
if (s.includes("running") || s.includes("progress") || s.includes("starting")) {
|
||||
running++;
|
||||
} else if (s.includes("completed") || s.includes("success")) {
|
||||
completed++;
|
||||
} else if (s.includes("failed") || s.includes("error")) {
|
||||
error++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$statTotal.textContent = items.length;
|
||||
$statRunning.textContent = running;
|
||||
$statCompleted.textContent = completed;
|
||||
$statError.textContent = error;
|
||||
}
|
||||
|
||||
// ========== 렌더링 ==========
|
||||
function renderTable(items) {
|
||||
if (!items) return;
|
||||
|
||||
const hash = JSON.stringify(items);
|
||||
if (hash === lastRenderHash) return;
|
||||
lastRenderHash = hash;
|
||||
|
||||
if (!items.length) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
|
||||
현재 모니터링 중인 Job이 없습니다.
|
||||
</td></tr>`;
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const it of items) {
|
||||
if (!it.ok) {
|
||||
rows.push(`<tr class="table-danger">
|
||||
<td><code>${escapeHtml(it.ip)}</code></td>
|
||||
<td colspan="6">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
오류: ${escapeHtml(it.error || "Unknown")}
|
||||
</td>
|
||||
</tr>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!it.jobs || !it.jobs.length) continue;
|
||||
|
||||
for (const j of it.jobs) {
|
||||
const recent = !!j.RecentlyCompleted;
|
||||
const timeText = j.CompletedAt
|
||||
? `완료: ${escapeHtml(j.CompletedAt.split('T')[1]?.split('.')[0] || j.CompletedAt)}`
|
||||
: escapeHtml(j.LastUpdateTime || "");
|
||||
|
||||
rows.push(`<tr ${recent ? 'class="table-success"' : ''}>
|
||||
<td><code>${escapeHtml(it.ip)}</code></td>
|
||||
<td><small class="font-monospace">${escapeHtml(j.JID || "")}</small></td>
|
||||
<td><strong>${escapeHtml(j.Name || "")}</strong></td>
|
||||
<td>${badgeStatus(j.Status || "", j.PercentComplete || "", recent)}</td>
|
||||
<td>${progressBar(j.PercentComplete || "0")}</td>
|
||||
<td><small>${escapeHtml(j.Message || "")}</small></td>
|
||||
<td><small class="text-muted">${timeText}</small></td>
|
||||
</tr>`);
|
||||
}
|
||||
}
|
||||
|
||||
$body.innerHTML = rows.length
|
||||
? rows.join("")
|
||||
: `<tr><td colspan="7" class="text-center text-success py-4">
|
||||
<i class="bi bi-check-circle fs-2 d-block mb-2"></i>
|
||||
현재 진행 중인 Job이 없습니다. ✅
|
||||
</td></tr>`;
|
||||
|
||||
updateStats(items);
|
||||
}
|
||||
|
||||
// ========== 서버 요청 ==========
|
||||
async function fetchJobs(auto = false) {
|
||||
if (!monitoringOn) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="bi bi-power fs-2 d-block mb-2"></i>
|
||||
모니터링이 꺼져 있습니다. 상단 스위치를 켜면 조회가 시작됩니다.
|
||||
</td></tr>`;
|
||||
$last.textContent = "";
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const ips = getIpsFromUI();
|
||||
if (!ips.length) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-warning py-4">
|
||||
<i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>
|
||||
IP 목록이 비어 있습니다.
|
||||
</td></tr>`;
|
||||
$last.textContent = "";
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$loading.classList.remove('d-none');
|
||||
$last.textContent = "조회 중… " + new Date().toLocaleTimeString();
|
||||
|
||||
const res = await fetch("/jobs/scan", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ips,
|
||||
method: "redfish", // Redfish 사용
|
||||
recency_hours: CONFIG.recency_hours,
|
||||
grace_minutes: CONFIG.grace_minutes,
|
||||
include_tracked_done: $showCompleted.checked
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || "Scan failed");
|
||||
|
||||
renderTable(data.items);
|
||||
$last.textContent = "업데이트: " + new Date().toLocaleString();
|
||||
} catch (e) {
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-danger text-center py-4">
|
||||
<i class="bi bi-exclamation-circle fs-2 d-block mb-2"></i>
|
||||
로드 실패: ${escapeHtml(e.message)}
|
||||
<br><button class="btn btn-sm btn-outline-primary mt-2" onclick="fetchJobs(false)">
|
||||
<i class="bi bi-arrow-clockwise"></i> 재시도
|
||||
</button>
|
||||
</td></tr>`;
|
||||
$last.textContent = "에러: " + new Date().toLocaleString();
|
||||
console.error(e);
|
||||
} finally {
|
||||
$loading.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 모니터링 제어 ==========
|
||||
function startAuto() {
|
||||
stopAuto();
|
||||
pollTimer = setInterval(() => fetchJobs(true), CONFIG.poll_interval_ms);
|
||||
}
|
||||
|
||||
function stopAuto() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateMonitorUI() {
|
||||
$btn.disabled = !monitoringOn;
|
||||
$auto.disabled = !monitoringOn;
|
||||
|
||||
if (monitoringOn) {
|
||||
$statusDot.classList.add('active');
|
||||
$statusText.textContent = '모니터링 중';
|
||||
} else {
|
||||
$statusDot.classList.remove('active');
|
||||
$statusText.textContent = '모니터링 꺼짐';
|
||||
}
|
||||
}
|
||||
|
||||
async function setMonitoring(on) {
|
||||
monitoringOn = !!on;
|
||||
localStorage.setItem(LS_MON, monitoringOn ? "1" : "0");
|
||||
updateMonitorUI();
|
||||
|
||||
if (!monitoringOn) {
|
||||
stopAuto();
|
||||
$last.textContent = "";
|
||||
$body.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">
|
||||
<i class="bi bi-power fs-2 d-block mb-2"></i>
|
||||
모니터링이 꺼져 있습니다.
|
||||
</td></tr>`;
|
||||
lastRenderHash = "";
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchJobs(false);
|
||||
if ($auto.checked) startAuto();
|
||||
}
|
||||
|
||||
// ========== 초기화 ==========
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 설정 로드
|
||||
try {
|
||||
const res = await fetch('/jobs/config');
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
CONFIG = data.config;
|
||||
$pollInterval.textContent = Math.round(CONFIG.poll_interval_ms / 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load config:", e);
|
||||
}
|
||||
|
||||
// IP 복원
|
||||
const savedIps = loadIps();
|
||||
if (savedIps.length) {
|
||||
setIpsToUI(savedIps);
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch('/jobs/iplist', {
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok && data.ips) {
|
||||
setIpsToUI(data.ips);
|
||||
saveIps();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 설정 복원
|
||||
const savedMon = localStorage.getItem(LS_MON);
|
||||
const savedAuto = localStorage.getItem(LS_AUTO);
|
||||
const savedShowCompleted = localStorage.getItem(LS_SHOW_COMPLETED);
|
||||
|
||||
$monSw.checked = savedMon === "1";
|
||||
$auto.checked = savedAuto === "1";
|
||||
$showCompleted.checked = savedShowCompleted !== "0";
|
||||
|
||||
// 이벤트
|
||||
$ipInput.addEventListener('input', updateIpCount);
|
||||
$btn.addEventListener('click', () => { if (monitoringOn) fetchJobs(false); });
|
||||
$auto.addEventListener('change', e => {
|
||||
localStorage.setItem(LS_AUTO, e.target.checked ? "1" : "0");
|
||||
if (!monitoringOn) return;
|
||||
if (e.target.checked) startAuto();
|
||||
else stopAuto();
|
||||
});
|
||||
$monSw.addEventListener('click', e => setMonitoring(e.target.checked));
|
||||
$showCompleted.addEventListener('change', e => {
|
||||
localStorage.setItem(LS_SHOW_COMPLETED, e.target.checked ? "1" : "0");
|
||||
if (monitoringOn) fetchJobs(false);
|
||||
});
|
||||
$btnLoad.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch('/jobs/iplist', {
|
||||
headers: { 'X-CSRFToken': csrfToken }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setIpsToUI(data.ips || []);
|
||||
saveIps();
|
||||
if (monitoringOn) await fetchJobs(false);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('IP 목록을 불러오는 중 오류가 발생했습니다: ' + e.message);
|
||||
}
|
||||
});
|
||||
$btnApply.addEventListener('click', () => {
|
||||
saveIps();
|
||||
if (monitoringOn) fetchJobs(false);
|
||||
});
|
||||
|
||||
// 초기 상태
|
||||
updateMonitorUI();
|
||||
updateIpCount();
|
||||
|
||||
if ($monSw.checked) {
|
||||
setMonitoring(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
function updateFileName(input) {
|
||||
const fileName = input.files[0]?.name || '파일 선택';
|
||||
document.getElementById('fileLabel').textContent = fileName;
|
||||
}
|
||||
|
||||
function openDeployModal(filename) {
|
||||
document.getElementById('deployFilename').value = filename;
|
||||
var myModal = new bootstrap.Modal(document.getElementById('deployModal'));
|
||||
myModal.show();
|
||||
}
|
||||
|
||||
function compareSelected() {
|
||||
const checkboxes = document.querySelectorAll('.file-selector:checked');
|
||||
if (checkboxes.length !== 2) {
|
||||
alert('비교할 파일을 정확히 2개 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
const file1 = checkboxes[0].value;
|
||||
const file2 = checkboxes[1].value;
|
||||
|
||||
// HTML 버튼의 data-url 속성에서 base URL을 가져옴
|
||||
const btn = document.getElementById('compareBtn');
|
||||
if (!btn) {
|
||||
console.error('compareBtn not found');
|
||||
return;
|
||||
}
|
||||
const baseUrl = btn.dataset.url;
|
||||
|
||||
window.location.href = `${baseUrl}?file1=${encodeURIComponent(file1)}&file2=${encodeURIComponent(file2)}`;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
let allServers = [];
|
||||
let analysisData = {};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadServers();
|
||||
|
||||
document.getElementById('check-all').addEventListener('change', (e) => {
|
||||
const checked = e.target.checked;
|
||||
document.querySelectorAll('.server-checkbox').forEach(cb => cb.checked = checked);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadServers() {
|
||||
try {
|
||||
const res = await axios.get('/idrac/api/servers');
|
||||
if (res.data.success) {
|
||||
allServers = res.data.servers;
|
||||
renderServerList(allServers);
|
||||
} else {
|
||||
alert('서버 목록 로드 실패: ' + res.data.message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('서버 목록 로드 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function renderServerList(servers) {
|
||||
const tbody = document.querySelector('#selection-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (servers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5">등록된 서버가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
servers.forEach(server => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td><input type="checkbox" class="server-checkbox" value="${server.id}"></td>
|
||||
<td>${server.name}</td>
|
||||
<td>${server.ip_address}</td>
|
||||
<td>${server.model || '-'}</td>
|
||||
<td>${server.status || 'Unknown'}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 시작: 서버 선택 후 동기화 (iDRAC -> DB)
|
||||
async function syncSelectedServers() {
|
||||
const selectedIds = Array.from(document.querySelectorAll('.server-checkbox:checked')).map(cb => parseInt(cb.value));
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
alert('동기화할 서버를 하나 이상 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`선택한 ${selectedIds.length}대 서버의 설정을 iDRAC에서 가져와 DB에 저장하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('loading').style.display = 'flex';
|
||||
updateLoadingMessage('설정 데이터 동기화 중...');
|
||||
|
||||
try {
|
||||
const res = await axios.post('/server-config/api/sync', {
|
||||
server_ids: selectedIds,
|
||||
categories: ['bios', 'system', 'idrac']
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
const failed = res.data.results.failed;
|
||||
if (failed.length > 0) {
|
||||
const failedNames = failed.map(f => f.name || f.id).join(', ');
|
||||
alert(`일부 서버 동기화 실패:\n${failedNames}\n\n성공한 서버는 DB에 저장되었습니다.`);
|
||||
} else {
|
||||
alert('모든 서버 동기화가 완료되었습니다. 이제 분석을 수행할 수 있습니다.');
|
||||
}
|
||||
|
||||
// 만약 이미 결과 화면이 보이고 있다면 자동 갱신
|
||||
if (document.getElementById('result-card').style.display !== 'none') {
|
||||
startAnalysis();
|
||||
}
|
||||
} else {
|
||||
alert('동기화 실패: ' + res.data.message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('동기화 요청 중 오류가 발생했습니다: ' + e.message);
|
||||
} finally {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 시작: DB 데이터 기반 분석 (DB -> Web)
|
||||
async function startAnalysis() {
|
||||
const selectedIds = Array.from(document.querySelectorAll('.server-checkbox:checked')).map(cb => parseInt(cb.value));
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
alert('분석할 서버를 하나 이상 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('loading').style.display = 'flex';
|
||||
document.getElementById('result-card').style.display = 'none';
|
||||
updateLoadingMessage('데이터 분석 중...');
|
||||
|
||||
try {
|
||||
const res = await axios.post('/server-config/api/fetch', {
|
||||
server_ids: selectedIds,
|
||||
categories: ['bios', 'system', 'idrac']
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
analysisData = res.data.results;
|
||||
renderComparison();
|
||||
document.getElementById('result-card').style.display = 'block';
|
||||
} else {
|
||||
alert('데이터 조회 실패: ' + res.data.message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('데이터 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateLoadingMessage(msg) {
|
||||
const loadingText = document.getElementById('loading-text');
|
||||
if (loadingText) loadingText.textContent = msg;
|
||||
}
|
||||
|
||||
function renderComparison() {
|
||||
const container = document.getElementById('result-container');
|
||||
const showDiffOnly = document.getElementById('show-diff-only').checked;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
const serverIds = Object.keys(analysisData);
|
||||
if (serverIds.length === 0) {
|
||||
container.innerHTML = '<p>데이터가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 통합된 모든 키(Attribute Name) 수집
|
||||
const allKeys = new Set();
|
||||
const serverNames = {};
|
||||
const serverUpdated = {}; // 마지막 업데이트 시간
|
||||
|
||||
serverIds.forEach(id => {
|
||||
const data = analysisData[id];
|
||||
serverNames[id] = data.name;
|
||||
|
||||
// 가장 최근 업데이트 시간 찾기
|
||||
const times = [data.bios_updated, data.idrac_updated, data.system_updated].filter(t => t);
|
||||
if (times.length > 0) {
|
||||
times.sort();
|
||||
serverUpdated[id] = times[times.length - 1]; // 가장 최신
|
||||
} else {
|
||||
serverUpdated[id] = 'N/A';
|
||||
}
|
||||
|
||||
// BIOS
|
||||
if (data.bios) Object.keys(data.bios).forEach(k => allKeys.add('BIOS: ' + k));
|
||||
// iDRAC
|
||||
if (data.idrac) Object.keys(data.idrac).forEach(k => allKeys.add('iDRAC: ' + k));
|
||||
// System
|
||||
if (data.system) Object.keys(flattenObject(data.system)).forEach(k => allKeys.add('System: ' + k));
|
||||
});
|
||||
|
||||
const sortedKeys = Array.from(allKeys).sort();
|
||||
|
||||
// 테이블 생성
|
||||
const table = document.createElement('table');
|
||||
table.className = 'comparison-table';
|
||||
|
||||
// 헤더
|
||||
const thead = document.createElement('thead');
|
||||
let headerHtml = '<tr><th style="min-width: 200px;">Attribute</th>';
|
||||
serverIds.forEach(id => {
|
||||
headerHtml += `<th>${serverNames[id]}</th>`;
|
||||
});
|
||||
headerHtml += '</tr>';
|
||||
|
||||
// 업데이트 시간 행 추가
|
||||
headerHtml += '<tr style="background-color: #fafafa; font-size: 0.8em; color: #666;"><th>Local DB Updated</th>';
|
||||
serverIds.forEach(id => {
|
||||
const timeStr = serverUpdated[id] !== 'N/A' ? new Date(serverUpdated[id]).toLocaleString() : 'Never';
|
||||
headerHtml += `<th>${timeStr}</th>`;
|
||||
});
|
||||
headerHtml += '</tr>';
|
||||
|
||||
thead.innerHTML = headerHtml;
|
||||
table.appendChild(thead);
|
||||
|
||||
// 바디
|
||||
const tbody = document.createElement('tbody');
|
||||
let hasDiff = false;
|
||||
|
||||
sortedKeys.forEach(key => {
|
||||
const row = document.createElement('tr');
|
||||
let rowHtml = `<td>${key}</td>`;
|
||||
|
||||
// 값 비교를 위한 배열
|
||||
const values = [];
|
||||
|
||||
serverIds.forEach(id => {
|
||||
const val = getValueByKey(analysisData[id], key);
|
||||
values.push(val);
|
||||
});
|
||||
|
||||
// 값들이 모두 같은지 확인
|
||||
const firstVal = values[0];
|
||||
const isDiff = !values.every(v => v === firstVal);
|
||||
|
||||
if (isDiff) {
|
||||
row.classList.add('diff-row');
|
||||
hasDiff = true;
|
||||
}
|
||||
|
||||
if (showDiffOnly && !isDiff) {
|
||||
return; // Diff Only 모드이고 차이가 없으면 건너뜀
|
||||
}
|
||||
|
||||
values.forEach(val => {
|
||||
const displayVal = (val === undefined || val === null) ? '<span style="color:#ccc">N/A</span>' : val;
|
||||
const cellClass = (isDiff && val !== firstVal) ? 'diff-highlight' : ''; // 첫번째 서버(기준)와 다르면 강조
|
||||
rowHtml += `<td class="${cellClass}">${displayVal}</td>`;
|
||||
});
|
||||
|
||||
row.innerHTML = rowHtml;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
if (showDiffOnly && !hasDiff) {
|
||||
// 차이점이 하나도 없는데 차이점만 보기 체크된 경우 빈 row가 아니라 메시지 표시가 나을지 고민
|
||||
// tbody가 비어있음
|
||||
}
|
||||
|
||||
if (tbody.children.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="' + (serverIds.length + 1) + '">표시할 항목이 없습니다.</td></tr>';
|
||||
}
|
||||
|
||||
table.appendChild(tbody);
|
||||
container.appendChild(table);
|
||||
}
|
||||
|
||||
// 헬퍼: 키(Category: Name)로 값 추출
|
||||
function getValueByKey(data, compositeKey) {
|
||||
if (!data) return undefined;
|
||||
|
||||
const [category, ...rest] = compositeKey.split(': ');
|
||||
const realKey = rest.join(': '); // 혹시 키 안에 콜론이 있을 경우 복구
|
||||
|
||||
if (category === 'BIOS') return data.bios ? data.bios[realKey] : undefined;
|
||||
if (category === 'iDRAC') return data.idrac ? data.idrac[realKey] : undefined;
|
||||
if (category === 'System') {
|
||||
const flatSys = flattenObject(data.system);
|
||||
return flatSys ? flatSys[realKey] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 객체 평탄화 (Nested Object -> Flat Key)
|
||||
function flattenObject(obj, prefix = '') {
|
||||
if (!obj) return {};
|
||||
return Object.keys(obj).reduce((acc, k) => {
|
||||
const pre = prefix.length ? prefix + '.' : '';
|
||||
if (typeof obj[k] === 'object' && obj[k] !== null) {
|
||||
Object.assign(acc, flattenObject(obj[k], pre + k));
|
||||
} else {
|
||||
acc[pre + k] = obj[k];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function downloadReport() {
|
||||
const table = document.querySelector('.comparison-table');
|
||||
if (!table) {
|
||||
alert('다운로드할 데이터가 없습니다. 먼저 분석을 진행해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
let csvContent = "\uFEFF"; // BOM for Excel UTF-8
|
||||
const rows = table.querySelectorAll('tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const cols = row.querySelectorAll('th, td');
|
||||
const rowData = [];
|
||||
|
||||
cols.forEach(col => {
|
||||
// 따옴표 처리 및 텍스트 추출
|
||||
let text = col.innerText || col.textContent;
|
||||
text = text.replace(/"/g, '""'); // 따옴표 이스케이프
|
||||
|
||||
// 줄바꿈 제거
|
||||
text = text.replace(/(\r\n|\n|\r)/gm, " ");
|
||||
|
||||
if (text.includes(',') || text.includes('"')) {
|
||||
text = `"${text}"`;
|
||||
}
|
||||
rowData.push(text);
|
||||
});
|
||||
|
||||
csvContent += rowData.join(',') + "\r\n";
|
||||
});
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `server_config_comparison_${timestamp}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/* style.css */
|
||||
|
||||
/* 기본 스타일 재정의 */
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #333;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
border-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
border-color: #545b62;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px; /* 파일 목록 아래 여백 추가 */
|
||||
}
|
||||
|
||||
.simple-card {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
width: calc(12.5% - 10px); /* 가로로 8개 유지 */
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: background-color 0.3s ease; /* 부드러운 전환 효과 추가 */
|
||||
}
|
||||
|
||||
.simple-card a {
|
||||
font-size: 1em; /* 글씨 크기를 작게 조정 */
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.simple-card a:hover {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
.simple-card:hover {
|
||||
background-color: #e9ecef; /* 호버 시 배경색 변경 */
|
||||
}
|
||||
|
||||
.button-group {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.backup-card-container .backup-card {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.accordion-button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.accordion-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
display: none;
|
||||
background-color: #f8f9fa;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 20px; /* 파일 목록 아래 여백 추가 */
|
||||
}
|
||||
|
||||
.accordion-content .card-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.accordion-content .simple-card {
|
||||
width: calc(12.5% - 10px); /* 가로로 8개 유지 */
|
||||
}
|
||||
|
||||
/* 진행 상황 스타일 */
|
||||
#progressSession {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
margin-bottom: 20px; /* 파일 목록 아래 여백 추가 */
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 25px;
|
||||
border-radius: 5px;
|
||||
background-color: #f4f4f4;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
font-weight: bold;
|
||||
line-height: 25px;
|
||||
background-color: #007bff;
|
||||
border-radius: 5px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
#progressSession h2 {
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
left: 15px;
|
||||
background-color: #e9ecef;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 1.2em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 서버 리스트 박스 높이 조정 */
|
||||
#server_list_content {
|
||||
height: 340px; /* 원하는 높이로 설정 */
|
||||
}
|
||||
|
||||
/* base */
|
||||
|
||||
.navbar {
|
||||
background-color: #333;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-list li {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.nav-list li a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nav-list li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: 58px; /* 상단 네비게이션 바의 높이 */
|
||||
}
|
||||
|
||||
/* XML 파일 목록의 스타일 정의 */
|
||||
.list-group-item {
|
||||
transition: background-color 0.3s ease, transform 0.3s ease;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
/* 마우스 오버 시 배경색 및 효과 변경 */
|
||||
.list-group-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 삭제 및 편집 버튼 간격 조정 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 파일 목록 타이틀 스타일 정의 */
|
||||
.card-body h5 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 파일 업로드 필드의 스타일 */
|
||||
.custom-file-label {
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
background-color: #f1f1f1;
|
||||
border: 1px solid #ccc;
|
||||
cursor: pointer;
|
||||
white-space: nowrap; /* 파일 이름이 한 줄로 보이게 함 */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis; /* 파일 이름이 길 경우 생략 표시 */
|
||||
}
|
||||
|
||||
.custom-file-label:hover {
|
||||
background-color: #e2e2e2;
|
||||
}
|
||||
|
||||
/* 업로드 버튼 스타일 */
|
||||
.btn-upload {
|
||||
margin-top: 10px;
|
||||
width: auto; /* 버튼 너비를 텍스트에 맞게 조정 */
|
||||
font-weight: bold;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* XML 파일 업로드 필드 레이아웃 조정 */
|
||||
.upload-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,834 @@
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
기본 레이아웃
|
||||
───────────────────────────────────────────────────────────── */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic",
|
||||
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
|
||||
font-weight: 400;
|
||||
background-color: #f8f9fa;
|
||||
padding-top: 56px;
|
||||
}
|
||||
|
||||
.container-card {
|
||||
background-color: #ffffff;
|
||||
padding: 20px;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.08);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
텍스트 및 제목 - 모두 일반 굵기
|
||||
───────────────────────────────────────────────────────────── */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #343a40;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.card-header h6 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
폼 요소 - 모두 일반 굵기
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.form-label {
|
||||
color: #495057;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ced4da;
|
||||
font-weight: 400;
|
||||
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: #80bdff;
|
||||
box-shadow: 0 0 0 0.25rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
버튼 - 일반 굵기
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
border-radius: 5px;
|
||||
font-weight: 400;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
네비게이션 바
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.navbar {
|
||||
background-color: #343a40 !important;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: rgba(255, 255, 255, 0.75) !important;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
카드 헤더 색상 (1번 이미지와 동일하게)
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.card-header.bg-primary {
|
||||
background-color: #007bff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.card-header.bg-success {
|
||||
background-color: #28a745 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.card-header.bg-primary h6,
|
||||
.card-header.bg-success h6 {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.card-header.bg-primary i,
|
||||
.card-header.bg-success i {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* 밝은 배경 헤더는 어두운 텍스트 */
|
||||
.card-header.bg-light {
|
||||
background-color: #f8f9fa !important;
|
||||
color: #343a40 !important;
|
||||
}
|
||||
|
||||
.card-header.bg-light h6 {
|
||||
color: #343a40 !important;
|
||||
}
|
||||
|
||||
.card-header.bg-light i {
|
||||
color: #343a40 !important;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
버튼 색상 (2번 이미지와 동일하게)
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.btn-warning {
|
||||
background-color: #ffc107 !important;
|
||||
border-color: #ffc107 !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
.btn-warning:hover {
|
||||
background-color: #e0a800 !important;
|
||||
border-color: #d39e00 !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background-color: #17a2b8 !important;
|
||||
border-color: #17a2b8 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.btn-info:hover {
|
||||
background-color: #138496 !important;
|
||||
border-color: #117a8b !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
진행바
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.progress {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
파일 그리드 레이아웃 - 빈 공간 없이 채우기
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
파일 카드 (컴팩트)
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.file-card-compact {
|
||||
transition: all 0.2s ease;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
max-width: 180px; /* 기본값 유지(카드가 너무 넓어지지 않도록) */
|
||||
}
|
||||
.file-card-compact:hover {
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.file-card-compact a {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 파일 카드 내 모든 텍스트 일반 굵기 */
|
||||
.file-card-compact,
|
||||
.file-card-compact * {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
(공통) 파일 카드 버튼 컨테이너 기본값 (기존 유지)
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.file-card-buttons { /* 처리된 목록(2버튼) 기본 레이아웃 */
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.file-card-buttons > button,
|
||||
.file-card-buttons > form {
|
||||
width: calc(50% - 0.075rem);
|
||||
}
|
||||
.file-card-buttons form { margin: 0; padding: 0; }
|
||||
.file-card-buttons .btn-sm {
|
||||
padding: 0.1rem 0.2rem !important;
|
||||
font-size: 0.65rem !important;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 1버튼(백업) 기본 레이아웃 */
|
||||
.file-card-single-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.file-card-single-button .btn-sm {
|
||||
padding: 0.15rem 0.3rem !important;
|
||||
font-size: 0.7rem !important;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
(공통) Outline 기본값 (기존 유지)
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.file-card-compact .btn-outline-primary {
|
||||
background-color: transparent !important;
|
||||
color: #0d6efd !important;
|
||||
border: 1px solid #0d6efd !important;
|
||||
}
|
||||
.file-card-compact .btn-outline-primary:hover {
|
||||
background-color: #0d6efd !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.file-card-compact .btn-outline-danger {
|
||||
background-color: transparent !important;
|
||||
color: #dc3545 !important;
|
||||
border: 1px solid #dc3545 !important;
|
||||
}
|
||||
.file-card-compact .btn-outline-danger:hover {
|
||||
background-color: #dc3545 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* 기존 d-flex gap-2 레거시 대응 */
|
||||
.file-card-compact .d-flex.gap-2 { display: flex; gap: 0.2rem; }
|
||||
.file-card-compact .d-flex.gap-2 > * { flex: 1; }
|
||||
.file-card-compact .d-flex.gap-2 form { display: contents; }
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
!!! 목록별 버튼 스타일 "분리" 규칙 (HTML에 클래스만 달아주면 적용)
|
||||
- processed-list 블록의 보기/삭제
|
||||
- backup-list 블록의 보기
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
/* 처리된 파일 목록(Processed) : 컨테이너 세부 튜닝 */
|
||||
.processed-list .file-card-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr; /* 보기/삭제 2열 격자 */
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
/* 보기(처리된) — 전용 클래스 우선 */
|
||||
.processed-list .btn-view-processed,
|
||||
.processed-list .file-card-buttons .btn-outline-primary { /* (백워드 호환) */
|
||||
border-color: #3b82f6 !important;
|
||||
color: #1d4ed8 !important;
|
||||
background: transparent !important;
|
||||
padding: .35rem .55rem !important;
|
||||
font-size: .8rem !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.processed-list .btn-view-processed:hover,
|
||||
.processed-list .file-card-buttons .btn-outline-primary:hover {
|
||||
background: rgba(59,130,246,.10) !important;
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
/* 삭제(처리된) — 전용 클래스 우선(더 작게) */
|
||||
.processed-list .btn-delete-processed,
|
||||
.processed-list .file-card-buttons .btn-outline-danger { /* (백워드 호환) */
|
||||
border-color: #ef4444 !important;
|
||||
color: #b91c1c !important;
|
||||
background: transparent !important;
|
||||
padding: .25rem .45rem !important; /* 더 작게 */
|
||||
font-size: .72rem !important; /* 더 작게 */
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.processed-list .btn-delete-processed:hover,
|
||||
.processed-list .file-card-buttons .btn-outline-danger:hover {
|
||||
background: rgba(239,68,68,.10) !important;
|
||||
color: #b91c1c !important;
|
||||
}
|
||||
|
||||
/* 백업 파일 목록(Backup) : 1버튼 컨테이너 */
|
||||
.backup-list .file-card-single-button {
|
||||
display: flex;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
/* 보기(백업) — 전용 클래스 우선(초록계열), 기존 .btn-outline-primary 사용 시에도 분리 적용 */
|
||||
.backup-list .btn-view-backup,
|
||||
.backup-list .file-card-single-button .btn-outline-primary { /* (백워드 호환) */
|
||||
width: 100%;
|
||||
border-color: #10b981 !important; /* emerald-500 */
|
||||
color: #047857 !important; /* emerald-700 */
|
||||
background: transparent !important;
|
||||
padding: .42rem .7rem !important;
|
||||
font-size: .8rem !important;
|
||||
font-weight: 700 !important; /* 백업은 강조 */
|
||||
}
|
||||
.backup-list .btn-view-backup:hover,
|
||||
.backup-list .file-card-single-button .btn-outline-primary:hover {
|
||||
background: rgba(16,185,129,.12) !important;
|
||||
color: #047857 !important;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
[★ 보완] 버튼 크기 “완전 통일”(처리/백업 공통)
|
||||
- 폰트/라인하이트/패딩을 변수화해서 두 목록 크기 동일
|
||||
- 기존 개별 padding/font-size를 덮어써서 시각적 높이 통일
|
||||
───────────────────────────────────────────────────────────── */
|
||||
:root{
|
||||
--btn-font: .80rem; /* 버튼 폰트 크기 통일 지점 */
|
||||
--btn-line: 1.2; /* 버튼 라인하이트 통일 지점 */
|
||||
--btn-py: .32rem; /* 수직 패딩 */
|
||||
--btn-px: .60rem; /* 좌우 패딩 */
|
||||
}
|
||||
|
||||
.processed-list .file-card-buttons .btn,
|
||||
.backup-list .file-card-single-button .btn {
|
||||
font-size: var(--btn-font) !important;
|
||||
line-height: var(--btn-line) !important;
|
||||
padding: var(--btn-py) var(--btn-px) !important;
|
||||
min-height: calc(1em * var(--btn-line) + (var(--btn-py) * 2)) !important;
|
||||
}
|
||||
|
||||
/* 이전 규칙보다 더 구체적으로 동일 규격을 한 번 더 보장 */
|
||||
.processed-list .file-card-buttons .btn.btn-outline,
|
||||
.backup-list .file-card-single-button .btn.btn-outline {
|
||||
font-size: var(--btn-font) !important;
|
||||
line-height: var(--btn-line) !important;
|
||||
padding: var(--btn-py) var(--btn-px) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
[★ 보완] 카드 “자동 한줄 배치”
|
||||
- 기존 Bootstrap .row.g-3를 Grid로 오버라이드(HTML 수정 無)
|
||||
- 우측 여백 최소화, 화면 너비에 맞춰 자연스럽게 줄 수 변경
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.processed-list .card-body > .row.g-3,
|
||||
.backup-list .card-body .row.g-3 {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
/* 그리드 기준으로 카드 폭이 잘 늘어나도록 제한 완화 */
|
||||
.processed-list .file-card-compact,
|
||||
.backup-list .file-card-compact {
|
||||
max-width: none !important; /* 기존 180px 제한을 목록 구간에 한해 해제 */
|
||||
min-width: 160px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
반응형 파일 그리드 (기존 유지)
|
||||
───────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 1400px) {
|
||||
.file-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.file-grid { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
.file-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.file-grid { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.file-grid { grid-template-columns: repeat(auto-fit, minmax(45%, 1fr)); }
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
백업 파일 리스트
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.list-group-item .bg-light {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.list-group-item:hover .bg-light {
|
||||
background-color: #e9ecef !important;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
모달 - 파일 내용 보기
|
||||
───────────────────────────────────────────────────────────── */
|
||||
#fileViewContent {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.modal-body pre::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.modal-body pre::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.modal-body pre::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.modal-body pre::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
접근성 - Skip to content
|
||||
───────────────────────────────────────────────────────────── */
|
||||
.visually-hidden-focusable:not(:focus):not(:focus-within) {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
.visually-hidden-focusable:focus {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
padding: 1rem;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
전역 폰트 굵기 강제 (Bootstrap 오버라이드)
|
||||
───────────────────────────────────────────────────────────── */
|
||||
* { font-weight: inherit; }
|
||||
strong, b { font-weight: 600; }
|
||||
label, .form-label, .card-title, .list-group-item strong {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
반응형
|
||||
───────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.card-body {
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
body {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* === [FIX] 처리된 목록 보기/삭제 버튼 크기 완전 동일화 === */
|
||||
|
||||
/* 1) 그리드 두 칸을 꽉 채우게 강제 */
|
||||
.processed-list .file-card-buttons {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr 1fr !important;
|
||||
gap: .2rem !important;
|
||||
align-items: stretch !important; /* 높이도 칸 높이에 맞춰 늘림 */
|
||||
}
|
||||
|
||||
/* 2) 그리드 아이템(버튼/폼) 자체를 칸 너비로 확장 */
|
||||
.processed-list .file-card-buttons > * {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* 3) 폼 안의 버튼도 100%로 확장 (폼이 그리드 아이템인 경우 대비) */
|
||||
.processed-list .file-card-buttons > form { display: block !important; }
|
||||
.processed-list .file-card-buttons > form > button {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* 4) 예전 플렉스 기반 전역 규칙 덮어쓰기(폭 계산식 무력화) */
|
||||
.processed-list .file-card-buttons > button,
|
||||
.processed-list .file-card-buttons > form {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* 5) 폰트/라인하이트/패딩 통일(높이 동일) — 필요 시 수치만 조정 */
|
||||
:root{
|
||||
--btn-font: .80rem;
|
||||
--btn-line: 1.2;
|
||||
--btn-py: .32rem;
|
||||
--btn-px: .60rem;
|
||||
}
|
||||
.processed-list .file-card-buttons .btn {
|
||||
font-size: var(--btn-font) !important;
|
||||
line-height: var(--btn-line) !important;
|
||||
padding: var(--btn-py) var(--btn-px) !important;
|
||||
min-height: calc(1em * var(--btn-line) + (var(--btn-py) * 2)) !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Imported from index.css & index_custom.css */
|
||||
/* ========================================================================== */
|
||||
|
||||
/* ===== 공통 ?�일 카드 컴팩???��???===== */
|
||||
.file-card-compact {
|
||||
transition: all 0.2s ease;
|
||||
background: #fff;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.file-card-compact:hover {
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.file-card-compact a {
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
/* ===== 목록��?버튼 분리 규칙 ===== */
|
||||
|
||||
/* 처리???�일 목록 ?�용 컨테?�너(보기/??�� 2?? */
|
||||
.processed-list .file-card-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
/* 보기(처리?? */
|
||||
.processed-list .btn-view-processed {
|
||||
border-color: #3b82f6;
|
||||
color: #1d4ed8;
|
||||
padding: .425rem .6rem;
|
||||
font-size: .8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.processed-list .btn-view-processed:hover {
|
||||
background: rgba(59, 130, 246, .08);
|
||||
}
|
||||
|
||||
/* ??��(처리?? ?????�게 */
|
||||
.processed-list .btn-delete-processed {
|
||||
border-color: #ef4444;
|
||||
color: #b91c1c;
|
||||
padding: .3rem .5rem;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.processed-list .btn-delete-processed:hover {
|
||||
background: rgba(239, 68, 68, .08);
|
||||
}
|
||||
|
||||
/* 백업 ?�일 목록 ?�용 컨테?�너(?�일 버튼) */
|
||||
.backup-list .file-card-single-button {
|
||||
display: flex;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
/* 보기(백업) ??강조 ?�상 */
|
||||
.backup-list .btn-view-backup {
|
||||
width: 100%;
|
||||
border-color: #10b981;
|
||||
color: #047857;
|
||||
padding: .45rem .75rem;
|
||||
font-size: .8125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.backup-list .btn-view-backup:hover {
|
||||
background: rgba(16, 185, 129, .08);
|
||||
}
|
||||
|
||||
/* ===== 백업 ?�일 ?�짜 ?�더 ===== */
|
||||
.list-group-item .bg-light {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.list-group-item:hover .bg-light {
|
||||
background-color: #e9ecef !important;
|
||||
}
|
||||
|
||||
/* ===== 진행��??�니메이??===== */
|
||||
.progress {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
/* ===== 반응???�스??===== */
|
||||
@media (max-width: 768px) {
|
||||
.card-body {
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== ?�크롤바 ?��??�링(모달) ===== */
|
||||
.modal-body pre::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.modal-body pre::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal-body pre::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal-body pre::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* 백업 ?�일 ?�중 ?�택 ?��???*/
|
||||
.backup-file-item.selected .file-card-compact {
|
||||
border-color: #0d6efd !important;
|
||||
background-color: #e7f1ff !important;
|
||||
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.file-card-compact {
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Tom Select 미세 조정 */
|
||||
.ts-wrapper.form-select {
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ts-control {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
.ts-wrapper.focus .ts-control {
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
/* Quick Move 버튼 ?�버 ?�과 */
|
||||
.btn-quick-move {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-quick-move:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .1) !important;
|
||||
background-color: #f8f9fa !important;
|
||||
border-color: #dee2e6 !important;
|
||||
}
|
||||
|
||||
.btn-quick-move:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Modern Minimalist Styles */
|
||||
.hover-bg-light {
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.hover-bg-light:hover {
|
||||
background-color: #f8f9fa !important;
|
||||
border-color: #dee2e6 !important;
|
||||
}
|
||||
|
||||
/* Slot Badge - Clean & Flat */
|
||||
.slot-badge {
|
||||
position: relative;
|
||||
padding: 0.4rem 0.8rem 0.4rem 2rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 9999px;
|
||||
/* Pill shape */
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.slot-badge:hover {
|
||||
border-color: #3b82f6;
|
||||
/* Primary Blue */
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.slot-index {
|
||||
position: absolute;
|
||||
left: 0.35rem;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Grid Layout for Preview */
|
||||
#slotPreview {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
/* 5 items per line */
|
||||
gap: 0.5rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* Slot Badge - Draggable & Card-like */
|
||||
.slot-badge {
|
||||
position: relative;
|
||||
padding: 0.5rem 0.2rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
/* Reduced font size */
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
height: 100%;
|
||||
min-height: 80px;
|
||||
overflow: hidden;
|
||||
/* Prevent overflow */
|
||||
word-break: break-all;
|
||||
/* Ensure wrapping if needed */
|
||||
}
|
||||
|
||||
.slot-badge:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.slot-badge:hover {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.slot-index {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.slot-badge:hover .slot-index {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Dragging state */
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
background: #e2e8f0;
|
||||
border: 1px dashed #94a3b8;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
opacity: 1;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
+5
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
+8
@@ -0,0 +1,8 @@
|
||||
/*!-----------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/vscode/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------*/define("vs/base/common/worker/simpleWorker.nls.de",{"vs/base/common/platform":["_"],"vs/editor/common/languages":["Array","Boolescher Wert","Klasse","Konstante","Konstruktor","Enumeration","Enumerationsmember","Ereignis","Feld","Datei","Funktion","Schnittstelle","Schl\xFCssel","Methode","Modul","Namespace","NULL","Zahl","Objekt","Operator","Paket","Eigenschaft","Zeichenfolge","Struktur","Typparameter","Variable","{0} ({1})"]});
|
||||
|
||||
//# sourceMappingURL=../../../../../min-maps/vs/base/common/worker/simpleWorker.nls.de.js.map
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*!-----------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/vscode/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------*/define("vs/base/common/worker/simpleWorker.nls.es",{"vs/base/common/platform":["_"],"vs/editor/common/languages":["matriz","booleano","clase","constante","constructor","enumeraci\xF3n","miembro de la enumeraci\xF3n","evento","campo","archivo","funci\xF3n","interfaz","clave","m\xE9todo","m\xF3dulo","espacio de nombres","NULL","n\xFAmero","objeto","operador","paquete","propiedad","cadena","estructura","par\xE1metro de tipo","variable","{0} ({1})"]});
|
||||
|
||||
//# sourceMappingURL=../../../../../min-maps/vs/base/common/worker/simpleWorker.nls.es.js.map
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*!-----------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/vscode/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------*/define("vs/base/common/worker/simpleWorker.nls.fr",{"vs/base/common/platform":["_"],"vs/editor/common/languages":["tableau","bool\xE9en","classe","constante","constructeur","\xE9num\xE9ration","membre d'\xE9num\xE9ration","\xE9v\xE9nement","champ","fichier","fonction","interface","cl\xE9","m\xE9thode","module","espace de noms","NULL","nombre","objet","op\xE9rateur","package","propri\xE9t\xE9","cha\xEEne","struct","param\xE8tre de type","variable","{0} ({1})"]});
|
||||
|
||||
//# sourceMappingURL=../../../../../min-maps/vs/base/common/worker/simpleWorker.nls.fr.js.map
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*!-----------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/vscode/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------*/define("vs/base/common/worker/simpleWorker.nls.it",{"vs/base/common/platform":["_"],"vs/editor/common/languages":["matrice","valore booleano","classe","costante","costruttore","enumerazione","membro di enumerazione","evento","campo","file","funzione","interfaccia","chiave","metodo","modulo","spazio dei nomi","Null","numero","oggetto","operatore","pacchetto","propriet\xE0","stringa","struct","parametro di tipo","variabile","{0} ({1})"]});
|
||||
|
||||
//# sourceMappingURL=../../../../../min-maps/vs/base/common/worker/simpleWorker.nls.it.js.map
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*!-----------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/vscode/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------*/define("vs/base/common/worker/simpleWorker.nls.ja",{"vs/base/common/platform":["_"],"vs/editor/common/languages":["\u914D\u5217","\u30D6\u30FC\u30EB\u5024","\u30AF\u30E9\u30B9","\u5B9A\u6570","\u30B3\u30F3\u30B9\u30C8\u30E9\u30AF\u30BF\u30FC","\u5217\u6319\u578B","\u5217\u6319\u578B\u30E1\u30F3\u30D0\u30FC","\u30A4\u30D9\u30F3\u30C8","\u30D5\u30A3\u30FC\u30EB\u30C9","\u30D5\u30A1\u30A4\u30EB","\u95A2\u6570","\u30A4\u30F3\u30BF\u30FC\u30D5\u30A7\u30A4\u30B9","\u30AD\u30FC","\u30E1\u30BD\u30C3\u30C9","\u30E2\u30B8\u30E5\u30FC\u30EB","\u540D\u524D\u7A7A\u9593","NULL","\u6570\u5024","\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8","\u6F14\u7B97\u5B50","\u30D1\u30C3\u30B1\u30FC\u30B8","\u30D7\u30ED\u30D1\u30C6\u30A3","\u6587\u5B57\u5217","\u69CB\u9020\u4F53","\u578B\u30D1\u30E9\u30E1\u30FC\u30BF\u30FC","\u5909\u6570","{0} ({1})"]});
|
||||
|
||||
//# sourceMappingURL=../../../../../min-maps/vs/base/common/worker/simpleWorker.nls.ja.js.map
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*!-----------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/vscode/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------*/define("vs/base/common/worker/simpleWorker.nls",{"vs/base/common/platform":["_"],"vs/editor/common/languages":["array","boolean","class","constant","constructor","enumeration","enumeration member","event","field","file","function","interface","key","method","module","namespace","null","number","object","operator","package","property","string","struct","type parameter","variable","{0} ({1})"]});
|
||||
|
||||
//# sourceMappingURL=../../../../../min-maps/vs/base/common/worker/simpleWorker.nls.js.map
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*!-----------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/vscode/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------*/define("vs/base/common/worker/simpleWorker.nls.ko",{"vs/base/common/platform":["_"],"vs/editor/common/languages":["\uBC30\uC5F4","\uBD80\uC6B8","\uD074\uB798\uC2A4","\uC0C1\uC218","\uC0DD\uC131\uC790","\uC5F4\uAC70\uD615","\uC5F4\uAC70\uD615 \uBA64\uBC84","\uC774\uBCA4\uD2B8","\uD544\uB4DC","\uD30C\uC77C","\uD568\uC218","\uC778\uD130\uD398\uC774\uC2A4","\uD0A4","\uBA54\uC11C\uB4DC","\uBAA8\uB4C8","\uB124\uC784\uC2A4\uD398\uC774\uC2A4","Null","\uC22B\uC790","\uAC1C\uCCB4","\uC5F0\uC0B0\uC790","\uD328\uD0A4\uC9C0","\uC18D\uC131","\uBB38\uC790\uC5F4","\uAD6C\uC870\uCCB4","\uD615\uC2DD \uB9E4\uAC1C \uBCC0\uC218","\uBCC0\uC218","{0}({1})"]});
|
||||
|
||||
//# sourceMappingURL=../../../../../min-maps/vs/base/common/worker/simpleWorker.nls.ko.js.map
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*!-----------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/vscode/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------*/define("vs/base/common/worker/simpleWorker.nls.ru",{"vs/base/common/platform":["_"],"vs/editor/common/languages":["\u043C\u0430\u0441\u0441\u0438\u0432","\u043B\u043E\u0433\u0438\u0447\u0435\u0441\u043A\u043E\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435","\u043A\u043B\u0430\u0441\u0441","\u043A\u043E\u043D\u0441\u0442\u0430\u043D\u0442\u0430","\u043A\u043E\u043D\u0441\u0442\u0440\u0443\u043A\u0442\u043E\u0440","\u043F\u0435\u0440\u0435\u0447\u0438\u0441\u043B\u0435\u043D\u0438\u0435","\u044D\u043B\u0435\u043C\u0435\u043D\u0442 \u043F\u0435\u0440\u0435\u0447\u0438\u0441\u043B\u0435\u043D\u0438\u044F","\u0441\u043E\u0431\u044B\u0442\u0438\u0435","\u043F\u043E\u043B\u0435","\u0444\u0430\u0439\u043B","\u0444\u0443\u043D\u043A\u0446\u0438\u044F","\u0438\u043D\u0442\u0435\u0440\u0444\u0435\u0439\u0441","\u043A\u043B\u044E\u0447","\u043C\u0435\u0442\u043E\u0434","\u043C\u043E\u0434\u0443\u043B\u044C","\u043F\u0440\u043E\u0441\u0442\u0440\u0430\u043D\u0441\u0442\u0432\u043E \u0438\u043C\u0435\u043D","NULL","\u0447\u0438\u0441\u043B\u043E","\u043E\u0431\u044A\u0435\u043A\u0442","\u043E\u043F\u0435\u0440\u0430\u0442\u043E\u0440","\u043F\u0430\u043A\u0435\u0442","\u0441\u0432\u043E\u0439\u0441\u0442\u0432\u043E","\u0441\u0442\u0440\u043E\u043A\u0430","\u0441\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0430","\u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 \u0442\u0438\u043F\u0430","\u041F\u0435\u0440\u0435\u043C\u0435\u043D\u043D\u0430\u044F","{0} ({1})"]});
|
||||
|
||||
//# sourceMappingURL=../../../../../min-maps/vs/base/common/worker/simpleWorker.nls.ru.js.map
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*!-----------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/vscode/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------*/define("vs/base/common/worker/simpleWorker.nls.zh-cn",{"vs/base/common/platform":["_"],"vs/editor/common/languages":["\u6570\u7EC4","\u5E03\u5C14\u503C","\u7C7B","\u5E38\u6570","\u6784\u9020\u51FD\u6570","\u679A\u4E3E","\u679A\u4E3E\u6210\u5458","\u4E8B\u4EF6","\u5B57\u6BB5","\u6587\u4EF6","\u51FD\u6570","\u63A5\u53E3","\u952E","\u65B9\u6CD5","\u6A21\u5757","\u547D\u540D\u7A7A\u95F4","Null","\u6570\u5B57","\u5BF9\u8C61","\u8FD0\u7B97\u7B26","\u5305","\u5C5E\u6027","\u5B57\u7B26\u4E32","\u7ED3\u6784","\u7C7B\u578B\u53C2\u6570","\u53D8\u91CF","{0} ({1})"]});
|
||||
|
||||
//# sourceMappingURL=../../../../../min-maps/vs/base/common/worker/simpleWorker.nls.zh-cn.js.map
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/*!-----------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/vscode/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------*/define("vs/base/common/worker/simpleWorker.nls.zh-tw",{"vs/base/common/platform":["_"],"vs/editor/common/languages":["\u9663\u5217","\u5E03\u6797\u503C","\u985E\u5225","\u5E38\u6578","\u5EFA\u69CB\u51FD\u5F0F","\u5217\u8209","\u5217\u8209\u6210\u54E1","\u4E8B\u4EF6","\u6B04\u4F4D","\u6A94\u6848","\u51FD\u5F0F","\u4ECB\u9762","\u7D22\u5F15\u9375","\u65B9\u6CD5","\u6A21\u7D44","\u547D\u540D\u7A7A\u9593","null","\u6578\u5B57","\u7269\u4EF6","\u904B\u7B97\u5B50","\u5957\u4EF6","\u5C6C\u6027","\u5B57\u4E32","\u7D50\u69CB","\u578B\u5225\u53C3\u6578","\u8B8A\u6578","{0} ({1})"]});
|
||||
|
||||
//# sourceMappingURL=../../../../../min-maps/vs/base/common/worker/simpleWorker.nls.zh-tw.js.map
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/apex/apex", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var i=Object.defineProperty;var r=Object.getOwnPropertyDescriptor;var c=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var d=(e,t)=>{for(var s in t)i(e,s,{get:t[s],enumerable:!0})},g=(e,t,s,a)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of c(t))!l.call(e,o)&&o!==s&&i(e,o,{get:()=>t[o],enumerable:!(a=r(t,o))||a.enumerable});return e};var p=e=>g(i({},"__esModule",{value:!0}),e);var h={};d(h,{conf:()=>m,language:()=>b});var m={wordPattern:/(-?\d*\.\d\w*)|([^\`\~\!\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,comments:{lineComment:"//",blockComment:["/*","*/"]},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"},{open:"<",close:">"}],folding:{markers:{start:new RegExp("^\\s*//\\s*(?:(?:#?region\\b)|(?:<editor-fold\\b))"),end:new RegExp("^\\s*//\\s*(?:(?:#?endregion\\b)|(?:</editor-fold>))")}}},u=["abstract","activate","and","any","array","as","asc","assert","autonomous","begin","bigdecimal","blob","boolean","break","bulk","by","case","cast","catch","char","class","collect","commit","const","continue","convertcurrency","decimal","default","delete","desc","do","double","else","end","enum","exception","exit","export","extends","false","final","finally","float","for","from","future","get","global","goto","group","having","hint","if","implements","import","in","inner","insert","instanceof","int","interface","into","join","last_90_days","last_month","last_n_days","last_week","like","limit","list","long","loop","map","merge","native","new","next_90_days","next_month","next_n_days","next_week","not","null","nulls","number","object","of","on","or","outer","override","package","parallel","pragma","private","protected","public","retrieve","return","returning","rollback","savepoint","search","select","set","short","sort","stat","static","strictfp","super","switch","synchronized","system","testmethod","then","this","this_month","this_week","throw","throws","today","tolabel","tomorrow","transaction","transient","trigger","true","try","type","undelete","update","upsert","using","virtual","void","volatile","webservice","when","where","while","yesterday"],f=e=>e.charAt(0).toUpperCase()+e.substr(1),n=[];u.forEach(e=>{n.push(e),n.push(e.toUpperCase()),n.push(f(e))});var b={defaultToken:"",tokenPostfix:".apex",keywords:n,operators:["=",">","<","!","~","?",":","==","<=",">=","!=","&&","||","++","--","+","-","*","/","&","|","^","%","<<",">>",">>>","+=","-=","*=","/=","&=","|=","^=","%=","<<=",">>=",">>>="],symbols:/[=><!~?:&|+\-*\/\^%]+/,escapes:/\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,digits:/\d+(_+\d+)*/,octaldigits:/[0-7]+(_+[0-7]+)*/,binarydigits:/[0-1]+(_+[0-1]+)*/,hexdigits:/[[0-9a-fA-F]+(_+[0-9a-fA-F]+)*/,tokenizer:{root:[[/[a-z_$][\w$]*/,{cases:{"@keywords":{token:"keyword.$0"},"@default":"identifier"}}],[/[A-Z][\w\$]*/,{cases:{"@keywords":{token:"keyword.$0"},"@default":"type.identifier"}}],{include:"@whitespace"},[/[{}()\[\]]/,"@brackets"],[/[<>](?!@symbols)/,"@brackets"],[/@symbols/,{cases:{"@operators":"delimiter","@default":""}}],[/@\s*[a-zA-Z_\$][\w\$]*/,"annotation"],[/(@digits)[eE]([\-+]?(@digits))?[fFdD]?/,"number.float"],[/(@digits)\.(@digits)([eE][\-+]?(@digits))?[fFdD]?/,"number.float"],[/(@digits)[fFdD]/,"number.float"],[/(@digits)[lL]?/,"number"],[/[;,.]/,"delimiter"],[/"([^"\\]|\\.)*$/,"string.invalid"],[/'([^'\\]|\\.)*$/,"string.invalid"],[/"/,"string",'@string."'],[/'/,"string","@string.'"],[/'[^\\']'/,"string"],[/(')(@escapes)(')/,["string","string.escape","string"]],[/'/,"string.invalid"]],whitespace:[[/[ \t\r\n]+/,""],[/\/\*\*(?!\/)/,"comment.doc","@apexdoc"],[/\/\*/,"comment","@comment"],[/\/\/.*$/,"comment"]],comment:[[/[^\/*]+/,"comment"],[/\*\//,"comment","@pop"],[/[\/*]/,"comment"]],apexdoc:[[/[^\/*]+/,"comment.doc"],[/\*\//,"comment.doc","@pop"],[/[\/*]/,"comment.doc"]],string:[[/[^\\"']+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/["']/,{cases:{"$#==$S2":{token:"string",next:"@pop"},"@default":"string"}}]]}};return p(h);})();
|
||||
return moduleExports;
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/azcli/azcli", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var s=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var r=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var c=(t,e)=>{for(var o in e)s(t,o,{get:e[o],enumerable:!0})},k=(t,e,o,a)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of r(e))!l.call(t,n)&&n!==o&&s(t,n,{get:()=>e[n],enumerable:!(a=i(e,n))||a.enumerable});return t};var p=t=>k(s({},"__esModule",{value:!0}),t);var d={};c(d,{conf:()=>f,language:()=>g});var f={comments:{lineComment:"#"}},g={defaultToken:"keyword",ignoreCase:!0,tokenPostfix:".azcli",str:/[^#\s]/,tokenizer:{root:[{include:"@comment"},[/\s-+@str*\s*/,{cases:{"@eos":{token:"key.identifier",next:"@popall"},"@default":{token:"key.identifier",next:"@type"}}}],[/^-+@str*\s*/,{cases:{"@eos":{token:"key.identifier",next:"@popall"},"@default":{token:"key.identifier",next:"@type"}}}]],type:[{include:"@comment"},[/-+@str*\s*/,{cases:{"@eos":{token:"key.identifier",next:"@popall"},"@default":"key.identifier"}}],[/@str+\s*/,{cases:{"@eos":{token:"string",next:"@popall"},"@default":"string"}}]],comment:[[/#.*$/,{cases:{"@eos":{token:"comment",next:"@popall"}}}]]}};return p(d);})();
|
||||
return moduleExports;
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/bat/bat", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var n=Object.defineProperty;var r=Object.getOwnPropertyDescriptor;var l=Object.getOwnPropertyNames;var i=Object.prototype.hasOwnProperty;var g=(o,e)=>{for(var t in e)n(o,t,{get:e[t],enumerable:!0})},c=(o,e,t,a)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of l(e))!i.call(o,s)&&s!==t&&n(o,s,{get:()=>e[s],enumerable:!(a=r(e,s))||a.enumerable});return o};var p=o=>c(n({},"__esModule",{value:!0}),o);var k={};g(k,{conf:()=>d,language:()=>m});var d={comments:{lineComment:"REM"},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'}],surroundingPairs:[{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'}],folding:{markers:{start:new RegExp("^\\s*(::\\s*|REM\\s+)#region"),end:new RegExp("^\\s*(::\\s*|REM\\s+)#endregion")}}},m={defaultToken:"",ignoreCase:!0,tokenPostfix:".bat",brackets:[{token:"delimiter.bracket",open:"{",close:"}"},{token:"delimiter.parenthesis",open:"(",close:")"},{token:"delimiter.square",open:"[",close:"]"}],keywords:/call|defined|echo|errorlevel|exist|for|goto|if|pause|set|shift|start|title|not|pushd|popd/,symbols:/[=><!~?&|+\-*\/\^;\.,]+/,escapes:/\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,tokenizer:{root:[[/^(\s*)(rem(?:\s.*|))$/,["","comment"]],[/(\@?)(@keywords)(?!\w)/,[{token:"keyword"},{token:"keyword.$2"}]],[/[ \t\r\n]+/,""],[/setlocal(?!\w)/,"keyword.tag-setlocal"],[/endlocal(?!\w)/,"keyword.tag-setlocal"],[/[a-zA-Z_]\w*/,""],[/:\w*/,"metatag"],[/%[^%]+%/,"variable"],[/%%[\w]+(?!\w)/,"variable"],[/[{}()\[\]]/,"@brackets"],[/@symbols/,"delimiter"],[/\d*\.\d+([eE][\-+]?\d+)?/,"number.float"],[/0[xX][0-9a-fA-F_]*[0-9a-fA-F]/,"number.hex"],[/\d+/,"number"],[/[;,.]/,"delimiter"],[/"/,"string",'@string."'],[/'/,"string","@string.'"]],string:[[/[^\\"'%]+/,{cases:{"@eos":{token:"string",next:"@popall"},"@default":"string"}}],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/%[\w ]+%/,"variable"],[/%%[\w]+(?!\w)/,"variable"],[/["']/,{cases:{"$#==$S2":{token:"string",next:"@pop"},"@default":"string"}}],[/$/,"string","@popall"]]}};return p(k);})();
|
||||
return moduleExports;
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/bicep/bicep", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var r=Object.defineProperty;var s=Object.getOwnPropertyDescriptor;var c=Object.getOwnPropertyNames;var a=Object.prototype.hasOwnProperty;var g=(e,n)=>{for(var o in n)r(e,o,{get:n[o],enumerable:!0})},l=(e,n,o,i)=>{if(n&&typeof n=="object"||typeof n=="function")for(let t of c(n))!a.call(e,t)&&t!==o&&r(e,t,{get:()=>n[t],enumerable:!(i=s(n,t))||i.enumerable});return e};var m=e=>l(r({},"__esModule",{value:!0}),e);var y={};g(y,{conf:()=>$,language:()=>w});var p=e=>`\\b${e}\\b`,k="[_a-zA-Z]",x="[_a-zA-Z0-9]",u=p(`${k}${x}*`),d=["targetScope","resource","module","param","var","output","for","in","if","existing"],b=["true","false","null"],f="[ \\t\\r\\n]",C="[0-9]+",$={comments:{lineComment:"//",blockComment:["/*","*/"]},brackets:[["{","}"],["[","]"],["(",")"]],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:"'",close:"'"},{open:"'''",close:"'''"}],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:"'",close:"'",notIn:["string","comment"]},{open:"'''",close:"'''",notIn:["string","comment"]}],autoCloseBefore:`:.,=}])'
|
||||
`,indentationRules:{increaseIndentPattern:new RegExp("^((?!\\/\\/).)*(\\{[^}\"'`]*|\\([^)\"'`]*|\\[[^\\]\"'`]*)$"),decreaseIndentPattern:new RegExp("^((?!.*?\\/\\*).*\\*/)?\\s*[\\}\\]].*$")}},w={defaultToken:"",tokenPostfix:".bicep",brackets:[{open:"{",close:"}",token:"delimiter.curly"},{open:"[",close:"]",token:"delimiter.square"},{open:"(",close:")",token:"delimiter.parenthesis"}],symbols:/[=><!~?:&|+\-*/^%]+/,keywords:d,namedLiterals:b,escapes:"\\\\(u{[0-9A-Fa-f]+}|n|r|t|\\\\|'|\\${)",tokenizer:{root:[{include:"@expression"},{include:"@whitespace"}],stringVerbatim:[{regex:"(|'|'')[^']",action:{token:"string"}},{regex:"'''",action:{token:"string.quote",next:"@pop"}}],stringLiteral:[{regex:"\\${",action:{token:"delimiter.bracket",next:"@bracketCounting"}},{regex:"[^\\\\'$]+",action:{token:"string"}},{regex:"@escapes",action:{token:"string.escape"}},{regex:"\\\\.",action:{token:"string.escape.invalid"}},{regex:"'",action:{token:"string",next:"@pop"}}],bracketCounting:[{regex:"{",action:{token:"delimiter.bracket",next:"@bracketCounting"}},{regex:"}",action:{token:"delimiter.bracket",next:"@pop"}},{include:"expression"}],comment:[{regex:"[^\\*]+",action:{token:"comment"}},{regex:"\\*\\/",action:{token:"comment",next:"@pop"}},{regex:"[\\/*]",action:{token:"comment"}}],whitespace:[{regex:f},{regex:"\\/\\*",action:{token:"comment",next:"@comment"}},{regex:"\\/\\/.*$",action:{token:"comment"}}],expression:[{regex:"'''",action:{token:"string.quote",next:"@stringVerbatim"}},{regex:"'",action:{token:"string.quote",next:"@stringLiteral"}},{regex:C,action:{token:"number"}},{regex:u,action:{cases:{"@keywords":{token:"keyword"},"@namedLiterals":{token:"keyword"},"@default":{token:"identifier"}}}}]}};return m(y);})();
|
||||
return moduleExports;
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/cameligo/cameligo", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var s=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var a=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var c=(o,e)=>{for(var n in e)s(o,n,{get:e[n],enumerable:!0})},m=(o,e,n,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of a(e))!l.call(o,t)&&t!==n&&s(o,t,{get:()=>e[t],enumerable:!(r=i(e,t))||r.enumerable});return o};var p=o=>m(s({},"__esModule",{value:!0}),o);var u={};c(u,{conf:()=>d,language:()=>g});var d={comments:{lineComment:"//",blockComment:["(*","*)"]},brackets:[["{","}"],["[","]"],["(",")"],["<",">"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'},{open:"(*",close:"*)"}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'},{open:"(*",close:"*)"}]},g={defaultToken:"",tokenPostfix:".cameligo",ignoreCase:!0,brackets:[{open:"{",close:"}",token:"delimiter.curly"},{open:"[",close:"]",token:"delimiter.square"},{open:"(",close:")",token:"delimiter.parenthesis"},{open:"<",close:">",token:"delimiter.angle"}],keywords:["abs","assert","block","Bytes","case","Crypto","Current","else","failwith","false","for","fun","if","in","let","let%entry","let%init","List","list","Map","map","match","match%nat","mod","not","operation","Operation","of","record","Set","set","sender","skip","source","String","then","to","true","type","with"],typeKeywords:["int","unit","string","tz","nat","bool"],operators:["=",">","<","<=",">=","<>",":",":=","and","mod","or","+","-","*","/","@","&","^","%","->","<-","&&","||"],symbols:/[=><:@\^&|+\-*\/\^%]+/,tokenizer:{root:[[/[a-zA-Z_][\w]*/,{cases:{"@keywords":{token:"keyword.$0"},"@default":"identifier"}}],{include:"@whitespace"},[/[{}()\[\]]/,"@brackets"],[/[<>](?!@symbols)/,"@brackets"],[/@symbols/,{cases:{"@operators":"delimiter","@default":""}}],[/\d*\.\d+([eE][\-+]?\d+)?/,"number.float"],[/\$[0-9a-fA-F]{1,16}/,"number.hex"],[/\d+/,"number"],[/[;,.]/,"delimiter"],[/'([^'\\]|\\.)*$/,"string.invalid"],[/'/,"string","@string"],[/'[^\\']'/,"string"],[/'/,"string.invalid"],[/\#\d+/,"string"]],comment:[[/[^\(\*]+/,"comment"],[/\*\)/,"comment","@pop"],[/\(\*/,"comment"]],string:[[/[^\\']+/,"string"],[/\\./,"string.escape.invalid"],[/'/,{token:"string.quote",bracket:"@close",next:"@pop"}]],whitespace:[[/[ \t\r\n]+/,"white"],[/\(\*/,"comment","@comment"],[/\/\/.*$/,"comment"]]}};return p(u);})();
|
||||
return moduleExports;
|
||||
});
|
||||
+10
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/coffee/coffee", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var s=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var g=Object.getOwnPropertyNames;var a=Object.prototype.hasOwnProperty;var l=(n,e)=>{for(var t in e)s(n,t,{get:e[t],enumerable:!0})},p=(n,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of g(e))!a.call(n,r)&&r!==t&&s(n,r,{get:()=>e[r],enumerable:!(o=i(e,r))||o.enumerable});return n};var c=n=>p(s({},"__esModule",{value:!0}),n);var m={};l(m,{conf:()=>d,language:()=>x});var d={wordPattern:/(-?\d*\.\d\w*)|([^\`\~\!\@\#%\^\&\*\(\)\=\$\-\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,comments:{blockComment:["###","###"],lineComment:"#"},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],folding:{markers:{start:new RegExp("^\\s*#region\\b"),end:new RegExp("^\\s*#endregion\\b")}}},x={defaultToken:"",ignoreCase:!0,tokenPostfix:".coffee",brackets:[{open:"{",close:"}",token:"delimiter.curly"},{open:"[",close:"]",token:"delimiter.square"},{open:"(",close:")",token:"delimiter.parenthesis"}],regEx:/\/(?!\/\/)(?:[^\/\\]|\\.)*\/[igm]*/,keywords:["and","or","is","isnt","not","on","yes","@","no","off","true","false","null","this","new","delete","typeof","in","instanceof","return","throw","break","continue","debugger","if","else","switch","for","while","do","try","catch","finally","class","extends","super","undefined","then","unless","until","loop","of","by","when"],symbols:/[=><!~?&%|+\-*\/\^\.,\:]+/,escapes:/\\(?:[abfnrtv\\"'$]|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,tokenizer:{root:[[/\@[a-zA-Z_]\w*/,"variable.predefined"],[/[a-zA-Z_]\w*/,{cases:{this:"variable.predefined","@keywords":{token:"keyword.$0"},"@default":""}}],[/[ \t\r\n]+/,""],[/###/,"comment","@comment"],[/#.*$/,"comment"],["///",{token:"regexp",next:"@hereregexp"}],[/^(\s*)(@regEx)/,["","regexp"]],[/(\()(\s*)(@regEx)/,["@brackets","","regexp"]],[/(\,)(\s*)(@regEx)/,["delimiter","","regexp"]],[/(\=)(\s*)(@regEx)/,["delimiter","","regexp"]],[/(\:)(\s*)(@regEx)/,["delimiter","","regexp"]],[/(\[)(\s*)(@regEx)/,["@brackets","","regexp"]],[/(\!)(\s*)(@regEx)/,["delimiter","","regexp"]],[/(\&)(\s*)(@regEx)/,["delimiter","","regexp"]],[/(\|)(\s*)(@regEx)/,["delimiter","","regexp"]],[/(\?)(\s*)(@regEx)/,["delimiter","","regexp"]],[/(\{)(\s*)(@regEx)/,["@brackets","","regexp"]],[/(\;)(\s*)(@regEx)/,["","","regexp"]],[/}/,{cases:{"$S2==interpolatedstring":{token:"string",next:"@pop"},"@default":"@brackets"}}],[/[{}()\[\]]/,"@brackets"],[/@symbols/,"delimiter"],[/\d+[eE]([\-+]?\d+)?/,"number.float"],[/\d+\.\d+([eE][\-+]?\d+)?/,"number.float"],[/0[xX][0-9a-fA-F]+/,"number.hex"],[/0[0-7]+(?!\d)/,"number.octal"],[/\d+/,"number"],[/[,.]/,"delimiter"],[/"""/,"string",'@herestring."""'],[/'''/,"string","@herestring.'''"],[/"/,{cases:{"@eos":"string","@default":{token:"string",next:'@string."'}}}],[/'/,{cases:{"@eos":"string","@default":{token:"string",next:"@string.'"}}}]],string:[[/[^"'\#\\]+/,"string"],[/@escapes/,"string.escape"],[/\./,"string.escape.invalid"],[/\./,"string.escape.invalid"],[/#{/,{cases:{'$S2=="':{token:"string",next:"root.interpolatedstring"},"@default":"string"}}],[/["']/,{cases:{"$#==$S2":{token:"string",next:"@pop"},"@default":"string"}}],[/#/,"string"]],herestring:[[/("""|''')/,{cases:{"$1==$S2":{token:"string",next:"@pop"},"@default":"string"}}],[/[^#\\'"]+/,"string"],[/['"]+/,"string"],[/@escapes/,"string.escape"],[/\./,"string.escape.invalid"],[/#{/,{token:"string.quote",next:"root.interpolatedstring"}],[/#/,"string"]],comment:[[/[^#]+/,"comment"],[/###/,"comment","@pop"],[/#/,"comment"]],hereregexp:[[/[^\\\/#]+/,"regexp"],[/\\./,"regexp"],[/#.*$/,"comment"],["///[igm]*",{token:"regexp",next:"@pop"}],[/\//,"regexp"]]}};return c(m);})();
|
||||
return moduleExports;
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/csharp/csharp", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var s=Object.defineProperty;var r=Object.getOwnPropertyDescriptor;var a=Object.getOwnPropertyNames;var c=Object.prototype.hasOwnProperty;var l=(t,e)=>{for(var o in e)s(t,o,{get:e[o],enumerable:!0})},p=(t,e,o,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of a(e))!c.call(t,n)&&n!==o&&s(t,n,{get:()=>e[n],enumerable:!(i=r(e,n))||i.enumerable});return t};var g=t=>p(s({},"__esModule",{value:!0}),t);var u={};l(u,{conf:()=>d,language:()=>m});var d={wordPattern:/(-?\d*\.\d\w*)|([^\`\~\!\#\$\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,comments:{lineComment:"//",blockComment:["/*","*/"]},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:"'",close:"'",notIn:["string","comment"]},{open:'"',close:'"',notIn:["string","comment"]}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'}],folding:{markers:{start:new RegExp("^\\s*#region\\b"),end:new RegExp("^\\s*#endregion\\b")}}},m={defaultToken:"",tokenPostfix:".cs",brackets:[{open:"{",close:"}",token:"delimiter.curly"},{open:"[",close:"]",token:"delimiter.square"},{open:"(",close:")",token:"delimiter.parenthesis"},{open:"<",close:">",token:"delimiter.angle"}],keywords:["extern","alias","using","bool","decimal","sbyte","byte","short","ushort","int","uint","long","ulong","char","float","double","object","dynamic","string","assembly","is","as","ref","out","this","base","new","typeof","void","checked","unchecked","default","delegate","var","const","if","else","switch","case","while","do","for","foreach","in","break","continue","goto","return","throw","try","catch","finally","lock","yield","from","let","where","join","on","equals","into","orderby","ascending","descending","select","group","by","namespace","partial","class","field","event","method","param","public","protected","internal","private","abstract","sealed","static","struct","readonly","volatile","virtual","override","params","get","set","add","remove","operator","true","false","implicit","explicit","interface","enum","null","async","await","fixed","sizeof","stackalloc","unsafe","nameof","when"],namespaceFollows:["namespace","using"],parenFollows:["if","for","while","switch","foreach","using","catch","when"],operators:["=","??","||","&&","|","^","&","==","!=","<=",">=","<<","+","-","*","/","%","!","~","++","--","+=","-=","*=","/=","%=","&=","|=","^=","<<=",">>=",">>","=>"],symbols:/[=><!~?:&|+\-*\/\^%]+/,escapes:/\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,tokenizer:{root:[[/\@?[a-zA-Z_]\w*/,{cases:{"@namespaceFollows":{token:"keyword.$0",next:"@namespace"},"@keywords":{token:"keyword.$0",next:"@qualified"},"@default":{token:"identifier",next:"@qualified"}}}],{include:"@whitespace"},[/}/,{cases:{"$S2==interpolatedstring":{token:"string.quote",next:"@pop"},"$S2==litinterpstring":{token:"string.quote",next:"@pop"},"@default":"@brackets"}}],[/[{}()\[\]]/,"@brackets"],[/[<>](?!@symbols)/,"@brackets"],[/@symbols/,{cases:{"@operators":"delimiter","@default":""}}],[/[0-9_]*\.[0-9_]+([eE][\-+]?\d+)?[fFdD]?/,"number.float"],[/0[xX][0-9a-fA-F_]+/,"number.hex"],[/0[bB][01_]+/,"number.hex"],[/[0-9_]+/,"number"],[/[;,.]/,"delimiter"],[/"([^"\\]|\\.)*$/,"string.invalid"],[/"/,{token:"string.quote",next:"@string"}],[/\$\@"/,{token:"string.quote",next:"@litinterpstring"}],[/\@"/,{token:"string.quote",next:"@litstring"}],[/\$"/,{token:"string.quote",next:"@interpolatedstring"}],[/'[^\\']'/,"string"],[/(')(@escapes)(')/,["string","string.escape","string"]],[/'/,"string.invalid"]],qualified:[[/[a-zA-Z_][\w]*/,{cases:{"@keywords":{token:"keyword.$0"},"@default":"identifier"}}],[/\./,"delimiter"],["","","@pop"]],namespace:[{include:"@whitespace"},[/[A-Z]\w*/,"namespace"],[/[\.=]/,"delimiter"],["","","@pop"]],comment:[[/[^\/*]+/,"comment"],["\\*/","comment","@pop"],[/[\/*]/,"comment"]],string:[[/[^\\"]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/"/,{token:"string.quote",next:"@pop"}]],litstring:[[/[^"]+/,"string"],[/""/,"string.escape"],[/"/,{token:"string.quote",next:"@pop"}]],litinterpstring:[[/[^"{]+/,"string"],[/""/,"string.escape"],[/{{/,"string.escape"],[/}}/,"string.escape"],[/{/,{token:"string.quote",next:"root.litinterpstring"}],[/"/,{token:"string.quote",next:"@pop"}]],interpolatedstring:[[/[^\\"{]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/{{/,"string.escape"],[/}}/,"string.escape"],[/{/,{token:"string.quote",next:"root.interpolatedstring"}],[/"/,{token:"string.quote",next:"@pop"}]],whitespace:[[/^[ \t\v\f]*#((r)|(load))(?=\s)/,"directive.csx"],[/^[ \t\v\f]*#\w.*$/,"namespace.cpp"],[/[ \t\v\f\r\n]+/,""],[/\/\*/,"comment","@comment"],[/\/\/.*$/,"comment"]]}};return g(u);})();
|
||||
return moduleExports;
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/csp/csp", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var o=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var u=Object.getOwnPropertyNames;var g=Object.prototype.hasOwnProperty;var a=(r,t)=>{for(var s in t)o(r,s,{get:t[s],enumerable:!0})},c=(r,t,s,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let e of u(t))!g.call(r,e)&&e!==s&&o(r,e,{get:()=>t[e],enumerable:!(n=i(t,e))||n.enumerable});return r};var q=r=>c(o({},"__esModule",{value:!0}),r);var p={};a(p,{conf:()=>f,language:()=>l});var f={brackets:[],autoClosingPairs:[],surroundingPairs:[]},l={keywords:[],typeKeywords:[],tokenPostfix:".csp",operators:[],symbols:/[=><!~?:&|+\-*\/\^%]+/,escapes:/\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,tokenizer:{root:[[/child-src/,"string.quote"],[/connect-src/,"string.quote"],[/default-src/,"string.quote"],[/font-src/,"string.quote"],[/frame-src/,"string.quote"],[/img-src/,"string.quote"],[/manifest-src/,"string.quote"],[/media-src/,"string.quote"],[/object-src/,"string.quote"],[/script-src/,"string.quote"],[/style-src/,"string.quote"],[/worker-src/,"string.quote"],[/base-uri/,"string.quote"],[/plugin-types/,"string.quote"],[/sandbox/,"string.quote"],[/disown-opener/,"string.quote"],[/form-action/,"string.quote"],[/frame-ancestors/,"string.quote"],[/report-uri/,"string.quote"],[/report-to/,"string.quote"],[/upgrade-insecure-requests/,"string.quote"],[/block-all-mixed-content/,"string.quote"],[/require-sri-for/,"string.quote"],[/reflected-xss/,"string.quote"],[/referrer/,"string.quote"],[/policy-uri/,"string.quote"],[/'self'/,"string.quote"],[/'unsafe-inline'/,"string.quote"],[/'unsafe-eval'/,"string.quote"],[/'strict-dynamic'/,"string.quote"],[/'unsafe-hashed-attributes'/,"string.quote"]]}};return q(p);})();
|
||||
return moduleExports;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/css/css", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var r=Object.defineProperty;var a=Object.getOwnPropertyDescriptor;var s=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var m=(t,e)=>{for(var o in e)r(t,o,{get:e[o],enumerable:!0})},c=(t,e,o,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of s(e))!l.call(t,n)&&n!==o&&r(t,n,{get:()=>e[n],enumerable:!(i=a(e,n))||i.enumerable});return t};var d=t=>c(r({},"__esModule",{value:!0}),t);var k={};m(k,{conf:()=>u,language:()=>p});var u={wordPattern:/(#?-?\d*\.\d\w*%?)|((::|[@#.!:])?[\w-?]+%?)|::|[@#.!:]/g,comments:{blockComment:["/*","*/"]},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}",notIn:["string","comment"]},{open:"[",close:"]",notIn:["string","comment"]},{open:"(",close:")",notIn:["string","comment"]},{open:'"',close:'"',notIn:["string","comment"]},{open:"'",close:"'",notIn:["string","comment"]}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],folding:{markers:{start:new RegExp("^\\s*\\/\\*\\s*#region\\b\\s*(.*?)\\s*\\*\\/"),end:new RegExp("^\\s*\\/\\*\\s*#endregion\\b.*\\*\\/")}}},p={defaultToken:"",tokenPostfix:".css",ws:`[
|
||||
\r\f]*`,identifier:"-?-?([a-zA-Z]|(\\\\(([0-9a-fA-F]{1,6}\\s?)|[^[0-9a-fA-F])))([\\w\\-]|(\\\\(([0-9a-fA-F]{1,6}\\s?)|[^[0-9a-fA-F])))*",brackets:[{open:"{",close:"}",token:"delimiter.bracket"},{open:"[",close:"]",token:"delimiter.bracket"},{open:"(",close:")",token:"delimiter.parenthesis"},{open:"<",close:">",token:"delimiter.angle"}],tokenizer:{root:[{include:"@selector"}],selector:[{include:"@comments"},{include:"@import"},{include:"@strings"},["[@](keyframes|-webkit-keyframes|-moz-keyframes|-o-keyframes)",{token:"keyword",next:"@keyframedeclaration"}],["[@](page|content|font-face|-moz-document)",{token:"keyword"}],["[@](charset|namespace)",{token:"keyword",next:"@declarationbody"}],["(url-prefix)(\\()",["attribute.value",{token:"delimiter.parenthesis",next:"@urldeclaration"}]],["(url)(\\()",["attribute.value",{token:"delimiter.parenthesis",next:"@urldeclaration"}]],{include:"@selectorname"},["[\\*]","tag"],["[>\\+,]","delimiter"],["\\[",{token:"delimiter.bracket",next:"@selectorattribute"}],["{",{token:"delimiter.bracket",next:"@selectorbody"}]],selectorbody:[{include:"@comments"},["[*_]?@identifier@ws:(?=(\\s|\\d|[^{;}]*[;}]))","attribute.name","@rulevalue"],["}",{token:"delimiter.bracket",next:"@pop"}]],selectorname:[["(\\.|#(?=[^{])|%|(@identifier)|:)+","tag"]],selectorattribute:[{include:"@term"},["]",{token:"delimiter.bracket",next:"@pop"}]],term:[{include:"@comments"},["(url-prefix)(\\()",["attribute.value",{token:"delimiter.parenthesis",next:"@urldeclaration"}]],["(url)(\\()",["attribute.value",{token:"delimiter.parenthesis",next:"@urldeclaration"}]],{include:"@functioninvocation"},{include:"@numbers"},{include:"@name"},{include:"@strings"},["([<>=\\+\\-\\*\\/\\^\\|\\~,])","delimiter"],[",","delimiter"]],rulevalue:[{include:"@comments"},{include:"@strings"},{include:"@term"},["!important","keyword"],[";","delimiter","@pop"],["(?=})",{token:"",next:"@pop"}]],warndebug:[["[@](warn|debug)",{token:"keyword",next:"@declarationbody"}]],import:[["[@](import)",{token:"keyword",next:"@declarationbody"}]],urldeclaration:[{include:"@strings"},[`[^)\r
|
||||
]+`,"string"],["\\)",{token:"delimiter.parenthesis",next:"@pop"}]],parenthizedterm:[{include:"@term"},["\\)",{token:"delimiter.parenthesis",next:"@pop"}]],declarationbody:[{include:"@term"},[";","delimiter","@pop"],["(?=})",{token:"",next:"@pop"}]],comments:[["\\/\\*","comment","@comment"],["\\/\\/+.*","comment"]],comment:[["\\*\\/","comment","@pop"],[/[^*/]+/,"comment"],[/./,"comment"]],name:[["@identifier","attribute.value"]],numbers:[["-?(\\d*\\.)?\\d+([eE][\\-+]?\\d+)?",{token:"attribute.value.number",next:"@units"}],["#[0-9a-fA-F_]+(?!\\w)","attribute.value.hex"]],units:[["(em|ex|ch|rem|fr|vmin|vmax|vw|vh|vm|cm|mm|in|px|pt|pc|deg|grad|rad|turn|s|ms|Hz|kHz|%)?","attribute.value.unit","@pop"]],keyframedeclaration:[["@identifier","attribute.value"],["{",{token:"delimiter.bracket",switchTo:"@keyframebody"}]],keyframebody:[{include:"@term"},["{",{token:"delimiter.bracket",next:"@selectorbody"}],["}",{token:"delimiter.bracket",next:"@pop"}]],functioninvocation:[["@identifier\\(",{token:"attribute.value",next:"@functionarguments"}]],functionarguments:[["\\$@identifier@ws:","attribute.name"],["[,]","delimiter"],{include:"@term"},["\\)",{token:"attribute.value",next:"@pop"}]],strings:[['~?"',{token:"string",next:"@stringenddoublequote"}],["~?'",{token:"string",next:"@stringendquote"}]],stringenddoublequote:[["\\\\.","string"],['"',{token:"string",next:"@pop"}],[/[^\\"]+/,"string"],[".","string"]],stringendquote:[["\\\\.","string"],["'",{token:"string",next:"@pop"}],[/[^\\']+/,"string"],[".","string"]]}};return d(k);})();
|
||||
return moduleExports;
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/cypher/cypher", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var s=Object.defineProperty;var r=Object.getOwnPropertyDescriptor;var a=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var c=(i,e)=>{for(var n in e)s(i,n,{get:e[n],enumerable:!0})},g=(i,e,n,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of a(e))!l.call(i,t)&&t!==n&&s(i,t,{get:()=>e[t],enumerable:!(o=r(e,t))||o.enumerable});return i};var p=i=>g(s({},"__esModule",{value:!0}),i);var u={};c(u,{conf:()=>d,language:()=>m});var d={comments:{lineComment:"//",blockComment:["/*","*/"]},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"},{open:"`",close:"`"}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"},{open:"`",close:"`"}]},m={defaultToken:"",tokenPostfix:".cypher",ignoreCase:!0,brackets:[{open:"{",close:"}",token:"delimiter.curly"},{open:"[",close:"]",token:"delimiter.bracket"},{open:"(",close:")",token:"delimiter.parenthesis"}],keywords:["ALL","AND","AS","ASC","ASCENDING","BY","CALL","CASE","CONTAINS","CREATE","DELETE","DESC","DESCENDING","DETACH","DISTINCT","ELSE","END","ENDS","EXISTS","IN","IS","LIMIT","MANDATORY","MATCH","MERGE","NOT","ON","ON","OPTIONAL","OR","ORDER","REMOVE","RETURN","SET","SKIP","STARTS","THEN","UNION","UNWIND","WHEN","WHERE","WITH","XOR","YIELD"],builtinLiterals:["true","TRUE","false","FALSE","null","NULL"],builtinFunctions:["abs","acos","asin","atan","atan2","avg","ceil","coalesce","collect","cos","cot","count","degrees","e","endNode","exists","exp","floor","head","id","keys","labels","last","left","length","log","log10","lTrim","max","min","nodes","percentileCont","percentileDisc","pi","properties","radians","rand","range","relationships","replace","reverse","right","round","rTrim","sign","sin","size","split","sqrt","startNode","stDev","stDevP","substring","sum","tail","tan","timestamp","toBoolean","toFloat","toInteger","toLower","toString","toUpper","trim","type"],operators:["+","-","*","/","%","^","=","<>","<",">","<=",">=","->","<-","-->","<--"],escapes:/\\(?:[tbnrf\\"'`]|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,digits:/\d+/,octaldigits:/[0-7]+/,hexdigits:/[0-9a-fA-F]+/,tokenizer:{root:[[/[{}[\]()]/,"@brackets"],{include:"common"}],common:[{include:"@whitespace"},{include:"@numbers"},{include:"@strings"},[/:[a-zA-Z_][\w]*/,"type.identifier"],[/[a-zA-Z_][\w]*(?=\()/,{cases:{"@builtinFunctions":"predefined.function"}}],[/[a-zA-Z_$][\w$]*/,{cases:{"@keywords":"keyword","@builtinLiterals":"predefined.literal","@default":"identifier"}}],[/`/,"identifier.escape","@identifierBacktick"],[/[;,.:|]/,"delimiter"],[/[<>=%+\-*/^]+/,{cases:{"@operators":"delimiter","@default":""}}]],numbers:[[/-?(@digits)[eE](-?(@digits))?/,"number.float"],[/-?(@digits)?\.(@digits)([eE]-?(@digits))?/,"number.float"],[/-?0x(@hexdigits)/,"number.hex"],[/-?0(@octaldigits)/,"number.octal"],[/-?(@digits)/,"number"]],strings:[[/"([^"\\]|\\.)*$/,"string.invalid"],[/'([^'\\]|\\.)*$/,"string.invalid"],[/"/,"string","@stringDouble"],[/'/,"string","@stringSingle"]],whitespace:[[/[ \t\r\n]+/,"white"],[/\/\*/,"comment","@comment"],[/\/\/.*$/,"comment"]],comment:[[/\/\/.*/,"comment"],[/[^/*]+/,"comment"],[/\*\//,"comment","@pop"],[/[/*]/,"comment"]],stringDouble:[[/[^\\"]+/,"string"],[/@escapes/,"string"],[/\\./,"string.invalid"],[/"/,"string","@pop"]],stringSingle:[[/[^\\']+/,"string"],[/@escapes/,"string"],[/\\./,"string.invalid"],[/'/,"string","@pop"]],identifierBacktick:[[/[^\\`]+/,"identifier.escape"],[/@escapes/,"identifier.escape"],[/\\./,"identifier.escape.invalid"],[/`/,"identifier.escape","@pop"]]}};return p(u);})();
|
||||
return moduleExports;
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/dart/dart", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var r=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var c=Object.getOwnPropertyNames;var a=Object.prototype.hasOwnProperty;var p=(n,e)=>{for(var t in e)r(n,t,{get:e[t],enumerable:!0})},g=(n,e,t,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of c(e))!a.call(n,o)&&o!==t&&r(n,o,{get:()=>e[o],enumerable:!(s=i(e,o))||s.enumerable});return n};var l=n=>g(r({},"__esModule",{value:!0}),n);var x={};p(x,{conf:()=>d,language:()=>m});var d={comments:{lineComment:"//",blockComment:["/*","*/"]},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:"'",close:"'",notIn:["string","comment"]},{open:'"',close:'"',notIn:["string"]},{open:"`",close:"`",notIn:["string","comment"]},{open:"/**",close:" */",notIn:["string"]}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:"<",close:">"},{open:"'",close:"'"},{open:"(",close:")"},{open:'"',close:'"'},{open:"`",close:"`"}],folding:{markers:{start:/^\s*\s*#?region\b/,end:/^\s*\s*#?endregion\b/}}},m={defaultToken:"invalid",tokenPostfix:".dart",keywords:["abstract","dynamic","implements","show","as","else","import","static","assert","enum","in","super","async","export","interface","switch","await","extends","is","sync","break","external","library","this","case","factory","mixin","throw","catch","false","new","true","class","final","null","try","const","finally","on","typedef","continue","for","operator","var","covariant","Function","part","void","default","get","rethrow","while","deferred","hide","return","with","do","if","set","yield"],typeKeywords:["int","double","String","bool"],operators:["+","-","*","/","~/","%","++","--","==","!=",">","<",">=","<=","=","-=","/=","%=",">>=","^=","+=","*=","~/=","<<=","&=","!=","||","&&","&","|","^","~","<<",">>","!",">>>","??","?",":","|="],symbols:/[=><!~?:&|+\-*\/\^%]+/,escapes:/\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,digits:/\d+(_+\d+)*/,octaldigits:/[0-7]+(_+[0-7]+)*/,binarydigits:/[0-1]+(_+[0-1]+)*/,hexdigits:/[[0-9a-fA-F]+(_+[0-9a-fA-F]+)*/,regexpctl:/[(){}\[\]\$\^|\-*+?\.]/,regexpesc:/\\(?:[bBdDfnrstvwWn0\\\/]|@regexpctl|c[A-Z]|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4})/,tokenizer:{root:[[/[{}]/,"delimiter.bracket"],{include:"common"}],common:[[/[a-z_$][\w$]*/,{cases:{"@typeKeywords":"type.identifier","@keywords":"keyword","@default":"identifier"}}],[/[A-Z_$][\w\$]*/,"type.identifier"],{include:"@whitespace"},[/\/(?=([^\\\/]|\\.)+\/([gimsuy]*)(\s*)(\.|;|,|\)|\]|\}|$))/,{token:"regexp",bracket:"@open",next:"@regexp"}],[/@[a-zA-Z]+/,"annotation"],[/[()\[\]]/,"@brackets"],[/[<>](?!@symbols)/,"@brackets"],[/!(?=([^=]|$))/,"delimiter"],[/@symbols/,{cases:{"@operators":"delimiter","@default":""}}],[/(@digits)[eE]([\-+]?(@digits))?/,"number.float"],[/(@digits)\.(@digits)([eE][\-+]?(@digits))?/,"number.float"],[/0[xX](@hexdigits)n?/,"number.hex"],[/0[oO]?(@octaldigits)n?/,"number.octal"],[/0[bB](@binarydigits)n?/,"number.binary"],[/(@digits)n?/,"number"],[/[;,.]/,"delimiter"],[/"([^"\\]|\\.)*$/,"string.invalid"],[/'([^'\\]|\\.)*$/,"string.invalid"],[/"/,"string","@string_double"],[/'/,"string","@string_single"]],whitespace:[[/[ \t\r\n]+/,""],[/\/\*\*(?!\/)/,"comment.doc","@jsdoc"],[/\/\*/,"comment","@comment"],[/\/\/\/.*$/,"comment.doc"],[/\/\/.*$/,"comment"]],comment:[[/[^\/*]+/,"comment"],[/\*\//,"comment","@pop"],[/[\/*]/,"comment"]],jsdoc:[[/[^\/*]+/,"comment.doc"],[/\*\//,"comment.doc","@pop"],[/[\/*]/,"comment.doc"]],regexp:[[/(\{)(\d+(?:,\d*)?)(\})/,["regexp.escape.control","regexp.escape.control","regexp.escape.control"]],[/(\[)(\^?)(?=(?:[^\]\\\/]|\\.)+)/,["regexp.escape.control",{token:"regexp.escape.control",next:"@regexrange"}]],[/(\()(\?:|\?=|\?!)/,["regexp.escape.control","regexp.escape.control"]],[/[()]/,"regexp.escape.control"],[/@regexpctl/,"regexp.escape.control"],[/[^\\\/]/,"regexp"],[/@regexpesc/,"regexp.escape"],[/\\\./,"regexp.invalid"],[/(\/)([gimsuy]*)/,[{token:"regexp",bracket:"@close",next:"@pop"},"keyword.other"]]],regexrange:[[/-/,"regexp.escape.control"],[/\^/,"regexp.invalid"],[/@regexpesc/,"regexp.escape"],[/[^\]]/,"regexp"],[/\]/,{token:"regexp.escape.control",next:"@pop",bracket:"@close"}]],string_double:[[/[^\\"\$]+/,"string"],[/[^\\"]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/"/,"string","@pop"],[/\$\w+/,"identifier"]],string_single:[[/[^\\'\$]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/'/,"string","@pop"],[/\$\w+/,"identifier"]]}};return l(x);})();
|
||||
return moduleExports;
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/dockerfile/dockerfile", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var a=Object.defineProperty;var l=Object.getOwnPropertyDescriptor;var r=Object.getOwnPropertyNames;var i=Object.prototype.hasOwnProperty;var p=(o,e)=>{for(var s in e)a(o,s,{get:e[s],enumerable:!0})},g=(o,e,s,t)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of r(e))!i.call(o,n)&&n!==s&&a(o,n,{get:()=>e[n],enumerable:!(t=l(e,n))||t.enumerable});return o};var c=o=>g(a({},"__esModule",{value:!0}),o);var k={};p(k,{conf:()=>u,language:()=>d});var u={brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}]},d={defaultToken:"",tokenPostfix:".dockerfile",variable:/\${?[\w]+}?/,tokenizer:{root:[{include:"@whitespace"},{include:"@comment"},[/(ONBUILD)(\s+)/,["keyword",""]],[/(ENV)(\s+)([\w]+)/,["keyword","",{token:"variable",next:"@arguments"}]],[/(FROM|MAINTAINER|RUN|EXPOSE|ENV|ADD|ARG|VOLUME|LABEL|USER|WORKDIR|COPY|CMD|STOPSIGNAL|SHELL|HEALTHCHECK|ENTRYPOINT)/,{token:"keyword",next:"@arguments"}]],arguments:[{include:"@whitespace"},{include:"@strings"},[/(@variable)/,{cases:{"@eos":{token:"variable",next:"@popall"},"@default":"variable"}}],[/\\/,{cases:{"@eos":"","@default":""}}],[/./,{cases:{"@eos":{token:"",next:"@popall"},"@default":""}}]],whitespace:[[/\s+/,{cases:{"@eos":{token:"",next:"@popall"},"@default":""}}]],comment:[[/(^#.*$)/,"comment","@popall"]],strings:[[/\\'$/,"","@popall"],[/\\'/,""],[/'$/,"string","@popall"],[/'/,"string","@stringBody"],[/"$/,"string","@popall"],[/"/,"string","@dblStringBody"]],stringBody:[[/[^\\\$']/,{cases:{"@eos":{token:"string",next:"@popall"},"@default":"string"}}],[/\\./,"string.escape"],[/'$/,"string","@popall"],[/'/,"string","@pop"],[/(@variable)/,"variable"],[/\\$/,"string"],[/$/,"string","@popall"]],dblStringBody:[[/[^\\\$"]/,{cases:{"@eos":{token:"string",next:"@popall"},"@default":"string"}}],[/\\./,"string.escape"],[/"$/,"string","@popall"],[/"/,"string","@pop"],[/(@variable)/,"variable"],[/\\$/,"string"],[/$/,"string","@popall"]]}};return c(k);})();
|
||||
return moduleExports;
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/flow9/flow9", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var s=Object.defineProperty;var r=Object.getOwnPropertyDescriptor;var a=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var c=(o,e)=>{for(var t in e)s(o,t,{get:e[t],enumerable:!0})},m=(o,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of a(e))!l.call(o,n)&&n!==t&&s(o,n,{get:()=>e[n],enumerable:!(i=r(e,n))||i.enumerable});return o};var p=o=>m(s({},"__esModule",{value:!0}),o);var u={};c(u,{conf:()=>g,language:()=>f});var g={comments:{blockComment:["/*","*/"],lineComment:"//"},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}",notIn:["string"]},{open:"[",close:"]",notIn:["string"]},{open:"(",close:")",notIn:["string"]},{open:'"',close:'"',notIn:["string"]},{open:"'",close:"'",notIn:["string"]}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"},{open:"<",close:">"}]},f={defaultToken:"",tokenPostfix:".flow",keywords:["import","require","export","forbid","native","if","else","cast","unsafe","switch","default"],types:["io","mutable","bool","int","double","string","flow","void","ref","true","false","with"],operators:["=",">","<","<=",">=","==","!","!=",":=","::=","&&","||","+","-","*","/","@","&","%",":","->","\\","$","??","^"],symbols:/[@$=><!~?:&|+\-*\\\/\^%]+/,escapes:/\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,tokenizer:{root:[[/[a-zA-Z_]\w*/,{cases:{"@keywords":"keyword","@types":"type","@default":"identifier"}}],{include:"@whitespace"},[/[{}()\[\]]/,"delimiter"],[/[<>](?!@symbols)/,"delimiter"],[/@symbols/,{cases:{"@operators":"delimiter","@default":""}}],[/((0(x|X)[0-9a-fA-F]*)|(([0-9]+\.?[0-9]*)|(\.[0-9]+))((e|E)(\+|-)?[0-9]+)?)/,"number"],[/[;,.]/,"delimiter"],[/"([^"\\]|\\.)*$/,"string.invalid"],[/"/,"string","@string"]],whitespace:[[/[ \t\r\n]+/,""],[/\/\*/,"comment","@comment"],[/\/\/.*$/,"comment"]],comment:[[/[^\/*]+/,"comment"],[/\*\//,"comment","@pop"],[/[\/*]/,"comment"]],string:[[/[^\\"]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/"/,"string","@pop"]]}};return p(u);})();
|
||||
return moduleExports;
|
||||
});
|
||||
+12
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
||||
"use strict";/*!-----------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Version: 0.45.0(5e5af013f8d295555a7210df0d5f2cea0bf5dd56)
|
||||
* Released under the MIT license
|
||||
* https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt
|
||||
*-----------------------------------------------------------------------------*/
|
||||
define("vs/basic-languages/fsharp/fsharp", ["require","require"],(require)=>{
|
||||
var moduleExports=(()=>{var s=Object.defineProperty;var r=Object.getOwnPropertyDescriptor;var a=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var c=(n,e)=>{for(var o in e)s(n,o,{get:e[o],enumerable:!0})},g=(n,e,o,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of a(e))!l.call(n,t)&&t!==o&&s(n,t,{get:()=>e[t],enumerable:!(i=r(e,t))||i.enumerable});return n};var f=n=>g(s({},"__esModule",{value:!0}),n);var d={};c(d,{conf:()=>m,language:()=>u});var m={comments:{lineComment:"//",blockComment:["(*","*)"]},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],folding:{markers:{start:new RegExp("^\\s*//\\s*#region\\b|^\\s*\\(\\*\\s*#region(.*)\\*\\)"),end:new RegExp("^\\s*//\\s*#endregion\\b|^\\s*\\(\\*\\s*#endregion\\s*\\*\\)")}}},u={defaultToken:"",tokenPostfix:".fs",keywords:["abstract","and","atomic","as","assert","asr","base","begin","break","checked","component","const","constraint","constructor","continue","class","default","delegate","do","done","downcast","downto","elif","else","end","exception","eager","event","external","extern","false","finally","for","fun","function","fixed","functor","global","if","in","include","inherit","inline","interface","internal","land","lor","lsl","lsr","lxor","lazy","let","match","member","mod","module","mutable","namespace","method","mixin","new","not","null","of","open","or","object","override","private","parallel","process","protected","pure","public","rec","return","static","sealed","struct","sig","then","to","true","tailcall","trait","try","type","upcast","use","val","void","virtual","volatile","when","while","with","yield"],symbols:/[=><!~?:&|+\-*\^%;\.,\/]+/,escapes:/\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,integersuffix:/[uU]?[yslnLI]?/,floatsuffix:/[fFmM]?/,tokenizer:{root:[[/[a-zA-Z_]\w*/,{cases:{"@keywords":{token:"keyword.$0"},"@default":"identifier"}}],{include:"@whitespace"},[/\[<.*>\]/,"annotation"],[/^#(if|else|endif)/,"keyword"],[/[{}()\[\]]/,"@brackets"],[/[<>](?!@symbols)/,"@brackets"],[/@symbols/,"delimiter"],[/\d*\d+[eE]([\-+]?\d+)?(@floatsuffix)/,"number.float"],[/\d*\.\d+([eE][\-+]?\d+)?(@floatsuffix)/,"number.float"],[/0x[0-9a-fA-F]+LF/,"number.float"],[/0x[0-9a-fA-F]+(@integersuffix)/,"number.hex"],[/0b[0-1]+(@integersuffix)/,"number.bin"],[/\d+(@integersuffix)/,"number"],[/[;,.]/,"delimiter"],[/"([^"\\]|\\.)*$/,"string.invalid"],[/"""/,"string",'@string."""'],[/"/,"string",'@string."'],[/\@"/,{token:"string.quote",next:"@litstring"}],[/'[^\\']'B?/,"string"],[/(')(@escapes)(')/,["string","string.escape","string"]],[/'/,"string.invalid"]],whitespace:[[/[ \t\r\n]+/,""],[/\(\*(?!\))/,"comment","@comment"],[/\/\/.*$/,"comment"]],comment:[[/[^*(]+/,"comment"],[/\*\)/,"comment","@pop"],[/\*/,"comment"],[/\(\*\)/,"comment"],[/\(/,"comment"]],string:[[/[^\\"]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/("""|"B?)/,{cases:{"$#==$S2":{token:"string",next:"@pop"},"@default":"string"}}]],litstring:[[/[^"]+/,"string"],[/""/,"string.escape"],[/"/,{token:"string.quote",next:"@pop"}]]}};return f(d);})();
|
||||
return moduleExports;
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user