Files
2026-05-05 17:14:11 +09:00

691 lines
30 KiB
HTML

{% extends "base.html" %}
{% block title %}스크립트 관리{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-code-square"></i> 스크립트 관리</h2>
<button class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#yamlGuideModal">
<i class="bi bi-question-circle"></i> YAML 작성 가이드
</button>
</div>
<!-- 탭 네비게이션 -->
<ul class="nav nav-tabs mb-3" id="scriptTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="mac-tab" data-bs-toggle="tab" data-bs-target="#mac" type="button">
<i class="bi bi-ethernet"></i> MAC 수집
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="server-tab" data-bs-toggle="tab" data-bs-target="#server" type="button">
<i class="bi bi-server"></i> 서버 정보
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="gpu-tab" data-bs-toggle="tab" data-bs-target="#gpu" type="button">
<i class="bi bi-gpu-card"></i> GPU 서버 정보
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="terminal-tab" data-bs-toggle="tab" data-bs-target="#terminal" type="button">
<i class="bi bi-terminal"></i> 터미널
</button>
</li>
</ul>
<!-- 탭 컨텐츠 -->
<div class="tab-content" id="scriptTabContent">
<!-- MAC 수집 탭 -->
<div class="tab-pane fade show active" id="mac" role="tabpanel">
<div class="row g-3" id="macProfiles">
<!-- 프로파일 카드가 여기에 동적으로 추가됨 -->
</div>
</div>
<!-- 서버 정보 탭 -->
<div class="tab-pane fade" id="server" role="tabpanel">
<div class="row g-3" id="serverProfiles">
<!-- 프로파일 카드가 여기에 동적으로 추가됨 -->
</div>
</div>
<!-- GPU 서버 정보 탭 -->
<div class="tab-pane fade" id="gpu" role="tabpanel">
<div class="row g-3" id="gpuProfiles">
<!-- 프로파일 카드가 여기에 동적으로 추가됨 -->
</div>
</div>
<!-- 터미널 탭 (Racadm Tester) -->
<div class="tab-pane fade" id="terminal" role="tabpanel">
<div class="row">
<div class="col-md-12">
<div class="card border shadow-sm">
<div class="card-header bg-light py-3">
<h5 class="mb-0"><i class="bi bi-terminal-fill me-2"></i>Racadm Command Tester</h5>
</div>
<div class="card-body p-4">
<form id="racadmForm" onsubmit="event.preventDefault(); runRacadmCommand();">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label fw-bold">iDRAC IP</label>
<input type="text" class="form-control" id="targetIp" placeholder="192.168.0.1"
required>
</div>
<div class="col-md-3">
<label class="form-label fw-bold">Username</label>
<input type="text" class="form-control" id="targetUser" value="root">
</div>
<div class="col-md-3">
<label class="form-label fw-bold">Password</label>
<input type="password" class="form-control" id="targetPass" value="calvin">
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-dark w-100" id="btnRunCommand">
<i class="bi bi-play-fill me-1"></i> Run Command
</button>
</div>
<div class="col-12">
<label class="form-label fw-bold">Command</label>
<div class="input-group">
<span class="input-group-text font-monospace bg-light">racadm</span>
<input type="text" class="form-control font-monospace" id="targetCmd"
placeholder="getsysinfo" value="getsysinfo" required>
</div>
<div class="form-text">Note: 'racadm' is automatically prepended. Just enter the
arguments (e.g. 'getsysinfo' or 'get NIC.NICConfig.1').</div>
</div>
</div>
</form>
<hr class="my-4">
<div>
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label fw-bold mb-0">Output</label>
<button class="btn btn-sm btn-outline-secondary"
onclick="document.getElementById('cmdOutput').innerText=''">
Clear
</button>
</div>
<div class="bg-dark text-light p-3 rounded font-monospace"
style="min-height: 200px; overflow: auto; resize: vertical;">
<pre id="cmdOutput" class="mb-0 text-break" style="white-space: pre-wrap;"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- YAML 가이드 모달 -->
<div class="modal fade" id="yamlGuideModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-book"></i> YAML 프로파일 작성 가이드</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- 탭 메뉴 -->
<ul class="nav nav-tabs mb-3" id="yamlGuideTabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#guide-basic"><i
class="bi bi-lightbulb"></i> YAML 기초</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#guide-mac">MAC 수집</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#guide-server">서버 정보</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#guide-gpu">GPU/GUID</button>
</li>
</ul>
<!-- 탭 컨텐츠 -->
<div class="tab-content border p-3 rounded bg-light" style="max-height: 60vh; overflow-y: auto;">
<!-- 1. YAML 기초 -->
<div class="tab-pane fade show active" id="guide-basic">
<h5 class="mb-3 text-primary">YAML 파일 작성 규칙 (초보자 필독)</h5>
<div class="alert alert-warning">
<strong><i class="bi bi-exclamation-triangle-fill"></i> 가장 중요한 규칙 2가지</strong>
<ol class="mb-0 mt-2">
<li><strong>들여쓰기(띄어쓰기)</strong>: 절대 <code>Tab</code> 키를 쓰지 마세요! <span
class="badge bg-danger">스페이스바 2칸</span>을 사용해야 합니다.</li>
<li><strong>콜론(:) 다음 공백</strong>: <code>key:value</code> (X) -> <code>key: value</code>
(O) <br>콜론 뒤에는 반드시 한 칸을 띄워야 합니다.</li>
</ol>
</div>
<div class="card mb-3">
<div class="card-header fw-bold">구조 이해하기</div>
<div class="card-body">
<p class="card-text">설정은 크게 <strong>이름(Key)</strong><strong>값(Value)</strong>으로 나뉩니다.
</p>
<div class="row">
<div class="col-md-6">
<h6><i class="bi bi-list-ul"></i> 목록 (List) 작성법</h6>
<p class="small text-muted">여러 개의 값을 넣으려면 대쉬(<code>-</code>)를 사용합니다.</p>
<pre class="bg-dark text-light p-2 rounded small"><code># 과일 목록 예시
fruits:
- "사과" # 리스트 1
- "바나나" # 리스트 2
- "포도" # 리스트 3</code></pre>
</div>
<div class="col-md-6">
<h6><i class="bi bi-card-text"></i> 설정 (Object) 작성법</h6>
<p class="small text-muted">하위 설정을 넣으려면 들여쓰기를 합니다.</p>
<pre class="bg-dark text-light p-2 rounded small"><code># 자동차 설정 예시
car:
color: "Red" # 스페이스 2칸
speed: 100 # 스페이스 2칸
is_active: true # 스페이스 2칸</code></pre>
</div>
</div>
</div>
</div>
</div>
<!-- 2. MAC 수집 -->
<div class="tab-pane fade" id="guide-mac">
<h5 class="mb-3">MAC 주소 수집 프로파일</h5>
<p class="text-muted">서버의 네트워크 카드(NIC)의 물리적 주소(MAC)를 가져오는 설정입니다.</p>
<div class="card mb-3 border-primary">
<div class="card-header bg-primary text-white">복사해서 사용하세요</div>
<div class="card-body bg-dark text-light p-0">
<pre class="m-0 p-3"><code>description: "기본 MAC 수집 설정"
# 1. 수집할 NIC(네트워크 카드)의 이름 패턴
nic_patterns:
- "NIC.Integrated.1" # 메인보드 내장 랜카드 1번
- "NIC.Slot.*" # 슬롯에 꽂힌 모든 랜카드 (PCIe)
# 2. InfiniBand(고속 네트워크) 카드가 있다면 슬롯 번호를 적으세요
infiniband_slots:
- "PCIeSlot3" # 3번 슬롯에 꽂힌 카드
# 3. iDRAC 전용 포트의 MAC도 필요하면 true로 설정
include_idrac_mac: true</code></pre>
</div>
</div>
<h6 class="mt-4"><i class="bi bi-question-circle-fill"></i> 상세 설명</h6>
<table class="table table-sm table-bordered bg-white">
<thead class="table-light">
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>nic_patterns</code></td>
<td>가져올 랜카드의 이름입니다. <code>*</code>(별표)는 '모두'를 의미합니다.<br>예: <code>NIC.*</code>는 모든
랜카드를 다 가져옵니다.</td>
</tr>
<tr>
<td><code>infiniband_slots</code></td>
<td>InfiniBand는 일반 NIC와 달라서 슬롯 번호(예: PCIeSlot3)를 정확히 적어야 합니다.</td>
</tr>
</tbody>
</table>
</div>
<!-- 3. 서버 정보 -->
<div class="tab-pane fade" id="guide-server">
<h5 class="mb-3">서버 정보 수집 프로파일</h5>
<p class="text-muted">서버의 펌웨어, BIOS 설정, 하드웨어 상태 등을 확인하는 설정입니다.</p>
<div class="card mb-3 border-success">
<div class="card-header bg-success text-white">복사해서 사용하세요</div>
<div class="card-body bg-dark text-light p-0">
<pre class="m-0 p-3"><code>description: "서버 점검용 설정"
# 1. 버전 정보를 알고 싶은 펌웨어 이름들
firmware:
- "iDRAC"
- "BIOS"
- "NIC"
# 2. 확인하고 싶은 BIOS 설정값
bios_settings:
- "MemTest" # 메모리 테스트 옵션
- "LogicalProc" # 하이퍼스레딩(논리코어)
# 3. RAID 컨트롤러의 디스크 정보
raid_settings:
- "Disk.Bay.0" # 0번 베이의 하드디스크 정보
# 4. 기타 명령어 실행 (상급자용)
custom_items:
- command: "getsysinfo"
label: "전체 시스템 요약"</code></pre>
</div>
</div>
</div>
<!-- 4. GPU/GUID -->
<div class="tab-pane fade" id="guide-gpu">
<h5 class="mb-3">GPU 및 GUID 설정</h5>
<p class="text-muted">GPU의 시리얼 번호를 수집하거나, iDRAC MAC 주소를 기반으로 GUID를 생성할 때 사용합니다.</p>
<div class="card mb-3 border-danger">
<div class="card-header bg-danger text-white">복사해서 사용하세요</div>
<div class="card-body bg-dark text-light p-0">
<pre class="m-0 p-3"><code>description: "GPU 서버용 설정"
# 1. GPU 시리얼 수집 설정 (GPU가 있는 경우 필수)
gpu_settings:
target_slots:
- "Video.Slot.*" # 모든 비디오 카드 수집
sort_order: "slot_index" # 슬롯 순서대로 정렬
# 2. GUID 생성 시 슬롯 우선순위
# iDRAC MAC 말고, 특정 랜카드의 MAC을 GUID로 쓰고 싶을 때 사용
slot_priority:
- 38 # 38번 슬롯 카드의 MAC을 1순위로 사용
- 1 # 없으면 1번 슬롯 사용
output_format: "combined"</code></pre>
</div>
</div>
<div class="alert alert-light border">
<strong><i class="bi bi-lightbulb"></i> 팁:</strong> GPU 시리얼만 필요하다면
<code>slot_priority</code> 부분은 지워도 됩니다.
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
</div>
</div>
</div>
</div>
<!-- 프로파일 편집 모달 -->
<div class="modal fade" id="profileModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="profileModalTitle">프로파일 편집</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">프로파일 이름</label>
<input type="text" class="form-control" id="profileName" readonly>
</div>
<div class="mb-3">
<label class="form-label">설명</label>
<input type="text" class="form-control" id="profileDescription">
</div>
<div class="mb-3">
<label class="form-label">설정 (YAML)</label>
<textarea class="form-control font-monospace" id="profileConfig" rows="15"></textarea>
<small class="text-muted">YAML 형식으로 프로파일 설정을 편집할 수 있습니다.</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" onclick="saveProfile()">
<i class="bi bi-save"></i> 저장
</button>
</div>
</div>
</div>
</div>
<style>
.profile-card {
transition: transform 0.2s, box-shadow 0.2s;
}
.profile-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.profile-card .card-body {
min-height: 180px;
}
.border-dashed {
border-style: dashed !important;
}
/* 탭 가시성 강제 설정 */
.nav-tabs .nav-link {
color: #495057 !important;
font-weight: 500;
}
.nav-tabs .nav-link.active {
color: #fff !important;
background-color: #0d6efd !important;
}
.nav-tabs .nav-link:hover {
color: #0a58ca !important;
}
</style>
<script>
let currentCategory = 'mac';
let currentProfile = null;
let allProfiles = [];
// 페이지 로드 시 프로파일 목록 가져오기
document.addEventListener('DOMContentLoaded', function () {
loadProfiles();
});
// 프로파일 목록 로드
async function loadProfiles() {
// 로딩 표시
['macProfiles', 'serverProfiles', 'gpuProfiles'].forEach(id => {
const el = document.getElementById(id);
if (el) el.innerHTML = '<div class="col-12 text-center py-5"><div class="spinner-border text-primary" role="status"></div><p class="mt-2 text-muted">프로파일을 불러오는 중입니다...</p></div>';
});
try {
const response = await fetch('/api/profiles');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
allProfiles = await response.json();
if (!Array.isArray(allProfiles)) throw new Error('데이터 형식이 올바르지 않습니다.');
renderProfiles();
} catch (error) {
console.error('프로파일 로드 오류:', error);
// 에러 표시
['macProfiles', 'serverProfiles', 'gpuProfiles'].forEach(id => {
const el = document.getElementById(id);
if (el) el.innerHTML = `<div class="col-12 text-center py-5"><i class="bi bi-exclamation-triangle text-danger fs-1"></i><p class="mt-2 text-danger">프로파일을 불러오지 못했습니다.<br><small>${error.message}</small></p></div>`;
});
}
}
// 프로파일 렌더링
function renderProfiles() {
const gpuContainer = document.getElementById('gpuProfiles');
const categories = {
'mac': document.getElementById('macProfiles'),
'server_info': document.getElementById('serverProfiles'),
'guid': gpuContainer,
'gpu': gpuContainer
};
// 각 카테고리 초기화
Object.values(categories).forEach(el => { if (el) el.innerHTML = ''; });
// 프로파일 카드 생성
allProfiles.forEach(profile => {
const container = categories[profile.category];
if (!container) return;
const card = createProfileCard(profile);
container.insertAdjacentHTML('beforeend', card);
});
// 새 프로파일 추가 카드
const targets = [
{ cat: 'mac', el: document.getElementById('macProfiles') },
{ cat: 'server_info', el: document.getElementById('serverProfiles') },
{ cat: 'gpu', el: gpuContainer }
];
targets.forEach(t => {
if (t.el) t.el.insertAdjacentHTML('beforeend', createNewProfileCard(t.cat));
});
}
// 프로파일 카드 HTML 생성
function createProfileCard(profile) {
const badgeClass = profile.is_default ? 'bg-success' : 'bg-secondary';
const badgeText = profile.is_default ? '기본' : '사용자 정의';
return `
<div class="col-md-4">
<div class="card profile-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title">
${profile.is_default ? '<i class="bi bi-star-fill text-warning"></i>' : ''}
${profile.name}
</h5>
<p class="card-text text-muted small">${profile.description}</p>
</div>
<span class="badge ${badgeClass}">${badgeText}</span>
</div>
<div class="mt-3">
<small class="text-muted">수집 항목:</small>
<div class="mt-1">
<span class="badge bg-light text-dark">${profile.items}개</span>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-sm btn-outline-primary flex-fill" onclick="editProfile('${profile.category}', '${profile.name}')">
<i class="bi bi-pencil"></i> 편집
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="exportProfile('${profile.category}', '${profile.name}')">
<i class="bi bi-download"></i> 내보내기
</button>
${!profile.is_default ? `
<button class="btn btn-sm btn-outline-danger" onclick="deleteProfile('${profile.category}', '${profile.name}')">
<i class="bi bi-trash"></i>
</button>
` : ''}
</div>
</div>
</div>
</div>
`;
}
// 새 프로파일 추가 카드
function createNewProfileCard(category) {
return `
<div class="col-md-4">
<div class="card profile-card border-dashed" style="border: 2px dashed #dee2e6;">
<div class="card-body d-flex flex-column align-items-center justify-content-center" style="min-height: 180px;">
<i class="bi bi-plus-circle fs-1 text-muted mb-3"></i>
<button class="btn btn-outline-primary" onclick="createNewProfile('${category}')">
새 프로파일 만들기
</button>
</div>
</div>
</div>
`;
}
// 프로파일 편집
async function editProfile(category, name) {
try {
const response = await fetch(`/api/profiles/${category}/${name}`);
const profile = await response.json();
currentCategory = category;
currentProfile = name;
document.getElementById('profileName').value = name;
document.getElementById('profileDescription').value = profile.description;
document.getElementById('profileConfig').value = JSON.stringify(profile.config, null, 2);
const modal = new bootstrap.Modal(document.getElementById('profileModal'));
modal.show();
} catch (error) {
console.error('프로파일 로드 오류:', error);
alert('프로파일을 불러오는 중 오류가 발생했습니다.');
}
}
// 프로파일 저장
async function saveProfile() {
try {
const config = JSON.parse(document.getElementById('profileConfig').value);
const response = await fetch(`/api/profiles/${currentCategory}/${currentProfile}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
config: config
})
});
const result = await response.json();
if (result.success) {
alert('프로파일이 저장되었습니다.');
bootstrap.Modal.getInstance(document.getElementById('profileModal')).hide();
loadProfiles();
} else {
alert('저장 실패: ' + result.error);
}
} catch (error) {
console.error('저장 오류:', error);
alert('프로파일 저장 중 오류가 발생했습니다.');
}
}
// 프로파일 내보내기
function exportProfile(category, name) {
window.location.href = `/api/profiles/${category}/export?name=${encodeURIComponent(name)}`;
}
// 프로파일 삭제
async function deleteProfile(category, name) {
if (!confirm(`'${name}' 프로파일을 삭제하시겠습니까?`)) return;
try {
const response = await fetch(`/api/profiles/${category}/${name}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
});
const result = await response.json();
if (result.success) {
alert('프로파일이 삭제되었습니다.');
loadProfiles();
} else {
alert('삭제 실패: ' + result.error);
}
} catch (error) {
console.error('삭제 오류:', error);
alert('프로파일 삭제 중 오류가 발생했습니다.');
}
}
// 새 프로파일 만들기
function createNewProfile(category) {
const name = prompt('새 프로파일 이름을 입력하세요:');
if (!name) return;
// 기본 설정으로 프로파일 생성
const defaultConfig = {
description: "새 프로파일",
extends: "default"
};
fetch(`/api/profiles/${category}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
name: name,
config: defaultConfig
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert('프로파일이 생성되었습니다.');
loadProfiles();
} else {
alert('생성 실패: ' + result.error);
}
})
.catch(error => {
console.error('생성 오류:', error);
alert('프로파일 생성 중 오류가 발생했습니다.');
});
}
async function runRacadmCommand() {
const ip = document.getElementById('targetIp').value;
const user = document.getElementById('targetUser').value;
const pass = document.getElementById('targetPass').value;
const cmd = document.getElementById('targetCmd').value;
const outputPre = document.getElementById('cmdOutput');
const btn = document.getElementById('btnRunCommand');
if (!ip || !user || !pass || !cmd) {
alert('모든 필드를 입력하세요.');
return;
}
// UI Reset
outputPre.innerText = "Executing...";
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Running...';
try {
const response = await fetch('/api/test_racadm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
ip: ip,
user: user,
password: pass,
command: cmd
})
});
const result = await response.json();
if (result.success) {
// Return code가 0이 아니면 실패로 간주하거나, 그대로 출력
let displayText = `[Success]\n${result.output}`;
if (result.return_code !== 0) {
displayText = `[Error (Code: ${result.return_code})]\n${result.output}`;
}
outputPre.innerText = displayText;
} else {
outputPre.innerText = `[Error]\n${result.error}`;
}
} catch (error) {
console.error('Execution error:', error);
outputPre.innerText = `[System Error]\n${error.message}`;
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-play-fill me-1"></i> Run Command';
}
}
</script>
{% endblock %}