Initial project upload

This commit is contained in:
Kim.KANGHEE
2026-05-05 17:14:11 +09:00
commit 122b73d254
282 changed files with 72135 additions and 0 deletions
+24
View File
@@ -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
+104
View File
@@ -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를 활성 탭에 따라 달리 보이게 할 수 있으면 반영
+188
View File
@@ -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)
+73
View File
@@ -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)
+66
View File
@@ -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)
+28
View File
@@ -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)
+116
View File
@@ -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("로그인")
+86
View File
@@ -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})>'
+134
View File
@@ -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
}
+62
View File
@@ -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})>'
+36
View File
@@ -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')
}
+17
View File
@@ -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)
+24
View File
@@ -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
}
+141
View File
@@ -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),
Werk­zeug 형식(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
+44
View File
@@ -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}")
+373
View File
@@ -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)
+380
View File
@@ -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"))
+789
View File
@@ -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()
+23
View File
@@ -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)}"
})
+61
View File
@@ -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
+109
View File
@@ -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
+13
View File
@@ -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")
+249
View File
@@ -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()
+279
View File
@@ -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")
+675
View File
@@ -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))
+166
View File
@@ -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"))
+325
View File
@@ -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)
+185
View File
@@ -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)}"})
+721
View File
@@ -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}")
+146
View File
@@ -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)}'
}
+178
View File
@@ -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
+308
View File
@@ -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()
+315
View File
@@ -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"])
+995
View File
@@ -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 {}
+170
View File
@@ -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
+107
View File
@@ -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
+320
View File
@@ -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
+173
View File
@@ -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)
+50
View File
@@ -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})
+13
View File
@@ -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

+76
View File
@@ -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;
}
}
+55
View File
@@ -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;
}
+779
View File
@@ -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;
}
}
+86
View File
@@ -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);
}
+296
View File
@@ -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

+51
View File
@@ -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;
}
// 제출 허용 (서버측에서도 반드시 검증)
});
})();
+721
View File
@@ -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
+385
View File
@@ -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');
}
}
+419
View File
@@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
}[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);
}
});
+30
View File
@@ -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)}`;
}
+324
View File
@@ -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);
}
+283
View File
@@ -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;
}
+834
View File
@@ -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);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
});
@@ -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;
});
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;
});
@@ -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;
});
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