917 lines
45 KiB
PHP
917 lines
45 KiB
PHP
@extends('administrator.layout.master')
|
|
|
|
@push('plugin-styles')
|
|
@foreach (config('assets.datatable.css') as $css)
|
|
<link href="{{ asset($css) }}" rel="stylesheet" />
|
|
@endforeach
|
|
{{-- AimaraJS --}}
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/rafaelthca/aimaraJS@master/css/Aimara.css" />
|
|
{{-- bs-stepper --}}
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bs-stepper/dist/css/bs-stepper.min.css" />
|
|
<style>
|
|
/* ── Layout ──────────────────────────────────────────── */
|
|
#vcenter-manager {
|
|
display: flex;
|
|
height: calc(100vh - 180px);
|
|
min-height: 540px;
|
|
border: 1px solid var(--bs-border-color);
|
|
border-radius: .5rem;
|
|
overflow: hidden;
|
|
background: var(--bs-body-bg);
|
|
}
|
|
|
|
/* ── Tree Panel ──────────────────────────────────────── */
|
|
#tree-panel {
|
|
width: 270px;
|
|
flex-shrink: 0;
|
|
border-right: 1px solid var(--bs-border-color);
|
|
overflow-y: auto;
|
|
background: var(--bs-tertiary-bg);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.tree-header {
|
|
padding: .6rem 1rem;
|
|
font-size: .7rem;
|
|
font-weight: 700;
|
|
letter-spacing: .07em;
|
|
text-transform: uppercase;
|
|
color: var(--bs-secondary-color);
|
|
border-bottom: 1px solid var(--bs-border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .4rem;
|
|
}
|
|
.tree-header svg { width: 13px; height: 13px; }
|
|
#tree-body { flex: 1; padding: .3rem 0; overflow-y: auto; }
|
|
|
|
/* ── AimaraJS overrides for Bootstrap theme ──────────── */
|
|
#tree-body ul.tree { margin: 0; padding: 0; }
|
|
#tree-body ul.tree li { font-size: .8rem; color: var(--bs-body-color); }
|
|
#tree-body span.node {
|
|
color: var(--bs-body-color);
|
|
border-radius: 4px;
|
|
padding: 2px 4px;
|
|
}
|
|
#tree-body span.node:hover {
|
|
background-color: var(--bs-secondary-bg) !important;
|
|
cursor: pointer;
|
|
}
|
|
#tree-body span.node_selected {
|
|
background-color: var(--bs-primary) !important;
|
|
color: #fff !important;
|
|
border-color: var(--bs-primary) !important;
|
|
}
|
|
#tree-body a { color: inherit !important; text-decoration: none !important; }
|
|
#tree-body span.node_selected a { color: #fff !important; }
|
|
|
|
/* ── Detail Panel ────────────────────────────────────── */
|
|
#detail-panel {
|
|
flex: 1; min-width: 0;
|
|
display: flex; flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
#detail-toolbar {
|
|
padding: .45rem .9rem;
|
|
border-bottom: 1px solid var(--bs-border-color);
|
|
background: var(--bs-tertiary-bg);
|
|
display: flex; align-items: center; gap: .5rem;
|
|
min-height: 42px;
|
|
}
|
|
#detail-breadcrumb { font-size: .78rem; }
|
|
#detail-content { flex: 1; overflow-y: auto; padding: 1.1rem 1.25rem; }
|
|
|
|
/* empty state */
|
|
.detail-empty {
|
|
height: 100%;
|
|
display: flex; flex-direction: column;
|
|
align-items: center; justify-content: center;
|
|
color: var(--bs-secondary-color); gap: .6rem;
|
|
}
|
|
.detail-empty svg { width: 40px; height: 40px; opacity: .25; }
|
|
|
|
/* ── Stat strip ──────────────────────────────────────── */
|
|
.stat-strip {
|
|
display: flex; gap: .75rem; flex-wrap: wrap; margin-bottom: 1.1rem;
|
|
}
|
|
.stat-card {
|
|
display: flex; flex-direction: column; align-items: center;
|
|
padding: .6rem .9rem;
|
|
border: 1px solid var(--bs-border-color);
|
|
border-radius: .4rem; min-width: 88px; text-align: center;
|
|
}
|
|
.stat-val { font-size: 1.35rem; font-weight: 700; line-height: 1; }
|
|
.stat-lbl { font-size: .68rem; color: var(--bs-secondary-color); margin-top: .2rem; }
|
|
|
|
/* ── Node header ─────────────────────────────────────── */
|
|
.node-header {
|
|
display: flex; align-items: center; gap: .75rem; margin-bottom: .9rem;
|
|
}
|
|
.node-avatar {
|
|
width: 44px; height: 44px; border-radius: 10px; flex-shrink: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.node-avatar svg { width: 22px; height: 22px; }
|
|
.node-title { font-size: 1rem; font-weight: 700; margin: 0; line-height: 1.2; }
|
|
.node-sub { font-size: .75rem; color: var(--bs-secondary-color); margin-top: .1rem; }
|
|
|
|
/* ip usage bar */
|
|
.ip-bar { height: 6px; border-radius: 3px; background: var(--bs-secondary-bg); overflow: hidden; }
|
|
.ip-bar-fill { height: 100%; background: #dc3545; border-radius: 3px; }
|
|
|
|
/* ── bs-stepper customise ────────────────────────────── */
|
|
.bs-stepper-header { padding: 1rem 1.25rem .5rem; border-bottom: 1px solid var(--bs-border-color); }
|
|
.bs-stepper-content { padding: 1.25rem; }
|
|
.bs-stepper .step-trigger:focus { box-shadow: none; }
|
|
.bs-stepper-circle { background-color: var(--bs-secondary); }
|
|
.step.active .bs-stepper-circle { background-color: var(--bs-primary); }
|
|
|
|
/* mode selection card */
|
|
.mode-card { cursor: pointer; border: 2px solid transparent !important; transition: border-color .15s; }
|
|
.mode-card.selected { border-color: var(--bs-primary) !important; }
|
|
.mode-card:hover { border-color: var(--bs-primary) !important; opacity: .85; }
|
|
</style>
|
|
@endpush
|
|
|
|
@section('breadcrumb')
|
|
@php
|
|
$breadcrumbItems = [
|
|
['label' => 'Infrastructure'],
|
|
['label' => 'vCenter Manager', 'active' => true],
|
|
];
|
|
@endphp
|
|
<x-admin-breadcrumb :items="$breadcrumbItems" />
|
|
@endsection
|
|
|
|
@section('content')
|
|
@if (auth()->guard('admins')->user()->can('manager package module'))
|
|
|
|
<div id="vcenter-manager">
|
|
|
|
{{-- ═══════ LEFT TREE (AimaraJS) ═══════ --}}
|
|
<div id="tree-panel">
|
|
<div class="tree-header d-flex justify-content-between align-items-center">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<i data-lucide="network"></i>
|
|
Navigator
|
|
</div>
|
|
<button class="btn btn-sm btn-primary p-0 px-1"
|
|
data-bs-toggle="modal"
|
|
data-bs-target="#wizardModal"
|
|
title="Thêm mới Infrastructure">
|
|
<i data-lucide="plus" style="width:14px;height:14px"></i>
|
|
</button>
|
|
</div>
|
|
<div id="tree-body"></div>
|
|
</div>
|
|
|
|
{{-- ═══════ RIGHT DETAIL ═══════ --}}
|
|
<div id="detail-panel">
|
|
<div id="detail-toolbar">
|
|
<span id="detail-breadcrumb" class="text-muted">
|
|
Select an item from the tree
|
|
</span>
|
|
</div>
|
|
<div id="detail-content">
|
|
<div class="detail-empty">
|
|
<i data-lucide="monitor-dot"></i>
|
|
<span class="small">Select an item from the tree to view details</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@else
|
|
<div class="alert alert-danger d-flex align-items-center gap-2">
|
|
<i data-lucide="shield-off"></i>
|
|
Access Denied — You are not assigned to any Module Package management department.
|
|
</div>
|
|
@endif
|
|
|
|
{{-- ═══════ WIZARD MODAL (Bootstrap Modal + bs-stepper) ═══════ --}}
|
|
<div class="modal fade" id="wizardModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i data-lucide="plus-circle" class="me-2" style="width:18px;height:18px"></i>
|
|
Thêm mới Infrastructure
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
|
|
<div class="modal-body p-0">
|
|
{{-- bs-stepper --}}
|
|
<div id="addInfraWizard" class="bs-stepper">
|
|
<div class="bs-stepper-header" role="tablist">
|
|
<div class="step" data-target="#step-vcenter">
|
|
<button type="button" class="step-trigger" role="tab"
|
|
aria-controls="step-vcenter" id="step-vcenter-trigger">
|
|
<span class="bs-stepper-circle">1</span>
|
|
<span class="bs-stepper-label">vCenter</span>
|
|
</button>
|
|
</div>
|
|
<div class="line"></div>
|
|
<div class="step" data-target="#step-dc-host">
|
|
<button type="button" class="step-trigger" role="tab"
|
|
aria-controls="step-dc-host" id="step-dc-host-trigger">
|
|
<span class="bs-stepper-circle">2</span>
|
|
<span class="bs-stepper-label">DC / Host</span>
|
|
</button>
|
|
</div>
|
|
<div class="line"></div>
|
|
<div class="step" data-target="#step-ds-nw">
|
|
<button type="button" class="step-trigger" role="tab"
|
|
aria-controls="step-ds-nw" id="step-ds-nw-trigger">
|
|
<span class="bs-stepper-circle">3</span>
|
|
<span class="bs-stepper-label">DS / Network</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bs-stepper-content">
|
|
{{-- Step 1: vCenter --}}
|
|
<div id="step-vcenter" class="content fade" role="tabpanel"
|
|
aria-labelledby="step-vcenter-trigger">
|
|
<div class="text-center mb-4">
|
|
<i data-lucide="cloud" class="text-primary" style="width:40px;height:40px"></i>
|
|
<h6 class="mt-2 mb-0">Chọn vCenter</h6>
|
|
</div>
|
|
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-6">
|
|
<div class="card h-100 text-center p-4 mode-card"
|
|
data-wiz-mode="create" data-wiz-section="wvc-create-section">
|
|
<i data-lucide="plus-circle" class="text-primary mb-2" style="width:28px;height:28px"></i>
|
|
<h6 class="mb-0">Tạo mới</h6>
|
|
</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<div class="card h-100 text-center p-4 mode-card"
|
|
data-wiz-mode="existing" data-wiz-section="wvc-existing-section">
|
|
<i data-lucide="server" class="text-secondary mb-2" style="width:28px;height:28px"></i>
|
|
<h6 class="mb-0">vCenter có sẵn</h6>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="wvc-create-section" class="d-none mt-3">
|
|
<h6 class="mb-3">Tạo vCenter mới</h6>
|
|
<div class="mb-2">
|
|
<label class="form-label">Host / IP *</label>
|
|
<input type="text" class="form-control" id="wvc-host" placeholder="vcsa.thegioiso.com">
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label">Username *</label>
|
|
<input type="text" class="form-control" id="wvc-user" placeholder="administrator@vsphere.local">
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label">Password *</label>
|
|
<input type="password" class="form-control" id="wvc-password">
|
|
</div>
|
|
<div class="mb-0">
|
|
<label class="form-label">Trạng thái</label>
|
|
<select class="form-select" id="wvc-status">
|
|
<option value="1">Hoạt động</option>
|
|
<option value="0">Không hoạt động</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="wvc-existing-section" class="d-none mt-3">
|
|
<h6 class="mb-3">Chọn vCenter có sẵn</h6>
|
|
<label class="form-label">vCenter Server</label>
|
|
<select class="form-select" id="wvc-select">
|
|
<option value="">-- Chọn vCenter --</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Step 2: Datacenter & Host --}}
|
|
<div id="step-dc-host" class="content fade" role="tabpanel"
|
|
aria-labelledby="step-dc-host-trigger">
|
|
<div class="text-center mb-4">
|
|
<i data-lucide="database" class="text-warning" style="width:40px;height:40px"></i>
|
|
<h6 class="mt-2 mb-0">Thêm Datacenter & Host</h6>
|
|
</div>
|
|
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-6">
|
|
<div class="card h-100 text-center p-4 mode-card"
|
|
data-wiz-submode="manual" data-wiz-section="wdc-manual-section">
|
|
<i data-lucide="edit-3" class="text-info mb-2" style="width:28px;height:28px"></i>
|
|
<h6 class="mb-0">Nhập thủ công</h6>
|
|
</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<div class="card h-100 text-center p-4 mode-card"
|
|
data-wiz-submode="sync" data-wiz-section="wdc-sync-section">
|
|
<i data-lucide="refresh-cw" class="text-success mb-2" style="width:28px;height:28px"></i>
|
|
<h6 class="mb-0">Sync từ vSphere</h6>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="wdc-manual-section" class="d-none mt-3">
|
|
<div class="mb-2">
|
|
<label class="form-label">Tên Datacenter *</label>
|
|
<input type="text" class="form-control" id="wdc-name" placeholder="DC1">
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label">Datacenter ID *</label>
|
|
<input type="text" class="form-control" id="wdc-id" placeholder="datacenter-1">
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label">Tên Host *</label>
|
|
<input type="text" class="form-control" id="whost-name" placeholder="esxi-01">
|
|
</div>
|
|
<div class="mb-0">
|
|
<label class="form-label">Host ID *</label>
|
|
<input type="text" class="form-control" id="whost-id" placeholder="host-1">
|
|
</div>
|
|
</div>
|
|
|
|
<div id="wdc-sync-section" class="d-none mt-3">
|
|
<div id="wdc-sync-loading" class="text-center py-2 d-none">
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
<span class="ms-2 small">Đang tải...</span>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Datacenter</label>
|
|
<select class="form-select" id="wdc-sync-select">
|
|
<option value="">-- Chọn Datacenter --</option>
|
|
</select>
|
|
</div>
|
|
<div id="whost-sync-wrap" class="d-none">
|
|
<label class="form-label">Host</label>
|
|
<select class="form-select" id="whost-sync-select">
|
|
<option value="">-- Chọn Host --</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Step 3: Datastore & Network --}}
|
|
<div id="step-ds-nw" class="content fade" role="tabpanel"
|
|
aria-labelledby="step-ds-nw-trigger">
|
|
<div class="text-center mb-4">
|
|
<i data-lucide="hard-drive" class="text-success" style="width:40px;height:40px"></i>
|
|
<h6 class="mt-2 mb-0">Chọn Datastore & Network</h6>
|
|
</div>
|
|
|
|
<div id="wds-loading" class="text-center py-3 d-none">
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
<span class="ms-2 small">Đang tải...</span>
|
|
</div>
|
|
|
|
<div id="wds-content" class="row">
|
|
<div class="col-md-6">
|
|
<label class="form-label small fw-semibold">
|
|
<i data-lucide="database" class="me-1" style="width:13px;height:13px"></i>
|
|
Datastores
|
|
</label>
|
|
<div class="border rounded" id="wds-list"
|
|
style="max-height:200px;overflow-y:auto">
|
|
<div class="p-3 text-muted small">Chưa có dữ liệu</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label small fw-semibold">
|
|
<i data-lucide="network" class="me-1" style="width:13px;height:13px"></i>
|
|
Networks
|
|
</label>
|
|
<div class="border rounded" id="wnw-list"
|
|
style="max-height:200px;overflow-y:auto">
|
|
<div class="p-3 text-muted small">Chưa có dữ liệu</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>{{-- /.bs-stepper-content --}}
|
|
</div>{{-- /#addInfraWizard --}}
|
|
</div>{{-- /.modal-body --}}
|
|
|
|
<div class="modal-footer" id="wizardFooter">
|
|
<div id="wiz-error" class="alert alert-danger py-1 px-3 small me-auto mb-0 d-none"></div>
|
|
<button type="button" id="btn-wiz-prev" class="btn btn-secondary d-none">
|
|
<i data-lucide="chevron-left" style="width:15px;height:15px"></i> Quay lại
|
|
</button>
|
|
<button type="button" id="btn-wiz-next" class="btn btn-primary">
|
|
Tiếp theo <i data-lucide="chevron-right" style="width:15px;height:15px"></i>
|
|
</button>
|
|
<button type="button" id="btn-wiz-save" class="btn btn-success d-none">
|
|
<i data-lucide="check" style="width:15px;height:15px"></i> Lưu
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
|
|
@push('plugin-scripts')
|
|
@foreach (config('assets.datatable.js') as $js)
|
|
<script src="{{ asset($js) }}"></script>
|
|
@endforeach
|
|
{{-- AimaraJS --}}
|
|
<script src="https://cdn.jsdelivr.net/gh/rafaelthca/aimaraJS@master/lib/Aimara.js"></script>
|
|
{{-- bs-stepper --}}
|
|
<script src="https://cdn.jsdelivr.net/npm/bs-stepper/dist/js/bs-stepper.min.js"></script>
|
|
|
|
<script>
|
|
/* ════════════════════════════════════════════════════════
|
|
Constants
|
|
════════════════════════════════════════════════════════ */
|
|
const CSRF = document.querySelector('meta[name="_token"]').content;
|
|
const ROUTE_CHILDREN = @json(route('vcenter.manager.tree.children'));
|
|
const ROUTE_DETAIL = @json(route('vcenter.manager.tree.detail'));
|
|
const ROUTE_VM_TABLE = @json(route('vcenter.manager.vm.datatable'));
|
|
const WIZ_ROUTES = {
|
|
vcenters : @json(route('vcenter.manager.wizard.vcenters')),
|
|
vcenterStore : @json(route('vcenter.manager.wizard.vcenter.store')),
|
|
datacenters : @json(route('vcenter.manager.wizard.datacenters')),
|
|
hosts : @json(route('vcenter.manager.wizard.hosts')),
|
|
datastoreNetwork: @json(route('vcenter.manager.wizard.datastore-network')),
|
|
save : @json(route('vcenter.manager.wizard.save')),
|
|
};
|
|
|
|
/* ════════════════════════════════════════════════════════
|
|
Helpers
|
|
════════════════════════════════════════════════════════ */
|
|
function reloadIcons(el) {
|
|
if (window.lucide) lucide.createIcons({ nameAttr: 'data-lucide', nodes: el ? [el] : undefined });
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════════════
|
|
AimaraJS Tree
|
|
════════════════════════════════════════════════════════ */
|
|
let tree = null;
|
|
let vmTable = null;
|
|
|
|
function initTree() {
|
|
tree = createTree('tree-body', '', null);
|
|
|
|
/* ── override selectNode to load detail panel ── */
|
|
const _origSelect = tree.selectNode.bind(tree);
|
|
tree.selectNode = function (node) {
|
|
_origSelect(node);
|
|
if (!node || !node.tag || node.tag.type === '_placeholder_') return;
|
|
const labels = {
|
|
vcenter: 'vCenter', datacenter: 'Datacenter',
|
|
host: 'Host', datastore: 'Datastore', network: 'Network',
|
|
};
|
|
document.getElementById('detail-breadcrumb').textContent =
|
|
(labels[node.tag.type] ?? node.tag.type) + ': ' + node.text;
|
|
loadDetail(node.tag.type, node.tag.id);
|
|
};
|
|
|
|
/* ── lazy-load children after node is opened ── */
|
|
tree.nodeAfterOpenEvent = function (node) {
|
|
if (!node || !node.tag || node.tag.loaded || node.tag.type === '_placeholder_') return;
|
|
node.tag.loaded = true;
|
|
|
|
fetch(`${ROUTE_CHILDREN}?type=${node.tag.type}&id=${node.tag.id}`, {
|
|
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json' },
|
|
})
|
|
.then(r => r.json())
|
|
.then(nodes => {
|
|
node.removeChildNodes();
|
|
if (!nodes.length) {
|
|
tree.createNode('(trống)', false, null, node, { type: '_placeholder_' }, null);
|
|
return;
|
|
}
|
|
nodes.forEach(child => {
|
|
const childNode = tree.createNode(
|
|
child.name, false, null, node,
|
|
{ type: child.type, id: child.id, loaded: !child.has_children, hasChildren: child.has_children },
|
|
null
|
|
);
|
|
if (child.has_children) {
|
|
tree.createNode('Đang tải...', false, null, childNode, { type: '_placeholder_' }, null);
|
|
}
|
|
});
|
|
})
|
|
.catch(() => {
|
|
node.removeChildNodes();
|
|
tree.createNode('Lỗi tải dữ liệu', false, null, node, { type: '_placeholder_' }, null);
|
|
});
|
|
};
|
|
|
|
/* ── build initial tree from PHP data ── */
|
|
@forelse($vcenters as $vc)
|
|
(function () {
|
|
const vcNode = tree.createNode(
|
|
@json($vc->host),
|
|
false, null, null,
|
|
{ type: 'vcenter', id: {{ $vc->id }}, loaded: true },
|
|
null
|
|
);
|
|
@foreach($vc->datacenters as $dc)
|
|
(function () {
|
|
const dcNode = tree.createNode(
|
|
@json($dc->name),
|
|
false, null, vcNode,
|
|
{ type: 'datacenter', id: {{ $dc->id }}, loaded: false, hasChildren: true },
|
|
null
|
|
);
|
|
tree.createNode('Đang tải...', false, null, dcNode, { type: '_placeholder_' }, null);
|
|
})();
|
|
@endforeach
|
|
})();
|
|
@empty
|
|
@endforelse
|
|
|
|
tree.drawTree();
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════════════
|
|
Detail Panel
|
|
════════════════════════════════════════════════════════ */
|
|
function loadDetail(type, id) {
|
|
const content = document.getElementById('detail-content');
|
|
content.innerHTML = `
|
|
<div style="height:180px;display:flex;align-items:center;justify-content:center">
|
|
<div class="spinner-border text-primary" style="width:1.8rem;height:1.8rem"></div>
|
|
</div>`;
|
|
|
|
if (vmTable) { vmTable.destroy(); vmTable = null; }
|
|
|
|
fetch(`${ROUTE_DETAIL}?type=${type}&id=${id}`, {
|
|
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json' },
|
|
})
|
|
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
.then(data => {
|
|
content.innerHTML = data.html;
|
|
reloadIcons(content);
|
|
|
|
const activeVmTab = content.querySelector('#tab-vms.active.show');
|
|
if (activeVmTab) initVmTable(type, id);
|
|
|
|
content.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => {
|
|
tab.addEventListener('shown.bs.tab', e => {
|
|
if (e.target.getAttribute('data-bs-target') === '#tab-vms') {
|
|
initVmTable(type, id, true);
|
|
}
|
|
vmTable?.columns.adjust();
|
|
});
|
|
});
|
|
})
|
|
.catch(err => {
|
|
content.innerHTML = `<div class="alert alert-danger">Lỗi tải: ${err.message}</div>`;
|
|
});
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════════════
|
|
VM DataTable
|
|
════════════════════════════════════════════════════════ */
|
|
function initVmTable(scopeType, scopeId, force = false) {
|
|
const el = document.getElementById('vm-datatable');
|
|
if (!el) return;
|
|
if (vmTable && !force) return;
|
|
if (vmTable) { vmTable.destroy(); vmTable = null; }
|
|
|
|
vmTable = $('#vm-datatable').DataTable({
|
|
processing: true,
|
|
serverSide: true,
|
|
ajax: {
|
|
url : ROUTE_VM_TABLE,
|
|
data: d => { d.scope_type = scopeType; d.scope_id = scopeId; },
|
|
},
|
|
columns: [
|
|
{ data: 'name', title: 'VM Name' },
|
|
{ data: 'customer_name',title: 'Khách hàng' },
|
|
{ data: 'ip_address', title: 'IP Address' },
|
|
{ data: 'vcenter_host', title: 'vCenter' },
|
|
{ data: 'os_name', title: 'OS' },
|
|
{ data: 'status_badge', title: 'Trạng thái', orderable: false, searchable: false },
|
|
],
|
|
pageLength: 10,
|
|
language: { processing: 'Đang tải…' },
|
|
});
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════════════
|
|
Wizard (bs-stepper + Bootstrap Modal)
|
|
════════════════════════════════════════════════════════ */
|
|
let wStepper = null;
|
|
let wStepIndex = 0;
|
|
let wMode = '';
|
|
let wSubMode = '';
|
|
let wSelectedVcenterId = '';
|
|
let wVcenters = [];
|
|
let wDatacenters = [];
|
|
let wSelectedDatacenterId = '';
|
|
let wHosts = [];
|
|
let wDatastores = [];
|
|
let wNetworks = [];
|
|
let wSelectedDs = []; /* indices */
|
|
let wSelectedNw = []; /* indices */
|
|
let wManualDc = {};
|
|
let wManualHost = {};
|
|
|
|
/* ── error helper ── */
|
|
function wizError(msg) {
|
|
const el = document.getElementById('wiz-error');
|
|
if (msg) { el.textContent = msg; el.classList.remove('d-none'); }
|
|
else el.classList.add('d-none');
|
|
}
|
|
|
|
/* ── footer buttons visibility ── */
|
|
function wizUpdateFooter() {
|
|
document.getElementById('btn-wiz-prev').classList.toggle('d-none', wStepIndex === 0);
|
|
document.getElementById('btn-wiz-next').classList.toggle('d-none', wStepIndex === 2);
|
|
document.getElementById('btn-wiz-save').classList.toggle('d-none', wStepIndex !== 2);
|
|
}
|
|
|
|
/* ── reset all wizard state ── */
|
|
function wizReset() {
|
|
wMode = ''; wSubMode = '';
|
|
wSelectedVcenterId = ''; wVcenters = [];
|
|
wDatacenters = []; wSelectedDatacenterId = '';
|
|
wHosts = []; wDatastores = []; wNetworks = [];
|
|
wSelectedDs = []; wSelectedNw = [];
|
|
wManualDc = {}; wManualHost = {};
|
|
wizError('');
|
|
|
|
/* reset form fields */
|
|
document.querySelectorAll('#addInfraWizard input').forEach(el => {
|
|
el.type === 'checkbox' ? (el.checked = false) : (el.value = '');
|
|
});
|
|
document.querySelectorAll('#addInfraWizard select').forEach(el => { el.selectedIndex = 0; });
|
|
document.querySelectorAll('#addInfraWizard .mode-card').forEach(c => c.classList.remove('selected'));
|
|
document.querySelectorAll('#wvc-create-section,#wvc-existing-section,#wdc-manual-section,#wdc-sync-section')
|
|
.forEach(s => s.classList.add('d-none'));
|
|
document.getElementById('whost-sync-wrap').classList.add('d-none');
|
|
document.getElementById('wds-list').innerHTML = '<div class="p-3 text-muted small">Chưa có dữ liệu</div>';
|
|
document.getElementById('wnw-list').innerHTML = '<div class="p-3 text-muted small">Chưa có dữ liệu</div>';
|
|
}
|
|
|
|
/* ── Step 1: load vcenters into select ── */
|
|
async function wizLoadVcenters() {
|
|
try {
|
|
const res = await fetch(WIZ_ROUTES.vcenters, { headers: { 'X-CSRF-TOKEN': CSRF } });
|
|
wVcenters = await res.json();
|
|
const sel = document.getElementById('wvc-select');
|
|
sel.innerHTML = '<option value="">-- Chọn vCenter --</option>';
|
|
wVcenters.forEach(vc => {
|
|
const o = new Option(vc.host + ' (' + vc.user + ')', vc.id);
|
|
sel.appendChild(o);
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
|
|
/* ── Step 2 (sync): load datacenters ── */
|
|
async function wizLoadDatacenters() {
|
|
if (!wSelectedVcenterId) return;
|
|
document.getElementById('wdc-sync-loading').classList.remove('d-none');
|
|
wizError('');
|
|
try {
|
|
const res = await fetch(WIZ_ROUTES.datacenters + '?vcenter_id=' + wSelectedVcenterId,
|
|
{ headers: { 'X-CSRF-TOKEN': CSRF } });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Lỗi');
|
|
wDatacenters = data;
|
|
const sel = document.getElementById('wdc-sync-select');
|
|
sel.innerHTML = '<option value="">-- Chọn Datacenter --</option>';
|
|
data.forEach(dc => sel.appendChild(new Option(dc.name, dc.datacenter_id)));
|
|
} catch (e) { wizError(e.message); }
|
|
document.getElementById('wdc-sync-loading').classList.add('d-none');
|
|
}
|
|
|
|
/* ── Step 2 (sync): load hosts on DC change ── */
|
|
async function wizLoadHosts() {
|
|
wSelectedDatacenterId = document.getElementById('wdc-sync-select').value;
|
|
if (!wSelectedVcenterId || !wSelectedDatacenterId) return;
|
|
document.getElementById('whost-sync-wrap').classList.add('d-none');
|
|
wizError('');
|
|
try {
|
|
const url = WIZ_ROUTES.hosts + '?vcenter_id=' + wSelectedVcenterId
|
|
+ '&datacenter_id=' + wSelectedDatacenterId;
|
|
const res = await fetch(url, { headers: { 'X-CSRF-TOKEN': CSRF } });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Lỗi');
|
|
wHosts = data;
|
|
const sel = document.getElementById('whost-sync-select');
|
|
sel.innerHTML = '<option value="">-- Chọn Host --</option>';
|
|
data.forEach(h => sel.appendChild(new Option(h.name, h.host_id)));
|
|
document.getElementById('whost-sync-wrap').classList.remove('d-none');
|
|
} catch (e) { wizError(e.message); }
|
|
}
|
|
|
|
/* ── Step 3: load datastores & networks ── */
|
|
async function wizLoadDsNw() {
|
|
const hostSel = document.getElementById('whost-sync-select');
|
|
const hostId = hostSel ? hostSel.value : '';
|
|
if (!wSelectedVcenterId || !hostId) return;
|
|
|
|
document.getElementById('wds-loading').classList.remove('d-none');
|
|
document.getElementById('wds-content').classList.add('d-none');
|
|
wizError('');
|
|
try {
|
|
const url = WIZ_ROUTES.datastoreNetwork + '?vcenter_id=' + wSelectedVcenterId
|
|
+ '&host_id=' + hostId;
|
|
const res = await fetch(url, { headers: { 'X-CSRF-TOKEN': CSRF } });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Lỗi');
|
|
wDatastores = data.datastores || [];
|
|
wNetworks = data.networks || [];
|
|
renderDsNwCheckboxes();
|
|
} catch (e) { wizError(e.message); }
|
|
document.getElementById('wds-loading').classList.add('d-none');
|
|
document.getElementById('wds-content').classList.remove('d-none');
|
|
reloadIcons(document.getElementById('wds-content'));
|
|
}
|
|
|
|
function renderDsNwCheckboxes() {
|
|
const mkList = (items, prefix) => items.length
|
|
? items.map((it, i) => `
|
|
<div class="form-check px-3 py-1 border-bottom">
|
|
<input class="form-check-input" type="checkbox" id="${prefix}${i}" value="${i}"
|
|
onchange="toggleCheck(${i},'${prefix}')">
|
|
<label class="form-check-label small" for="${prefix}${i}">${it.name} (${it.type})</label>
|
|
</div>`).join('')
|
|
: '<div class="p-3 text-muted small">Không có dữ liệu</div>';
|
|
|
|
document.getElementById('wds-list').innerHTML = mkList(wDatastores, 'wds-');
|
|
document.getElementById('wnw-list').innerHTML = mkList(wNetworks, 'wnw-');
|
|
}
|
|
|
|
function toggleCheck(i, prefix) {
|
|
const target = prefix === 'wds-' ? wSelectedDs : wSelectedNw;
|
|
const idx = target.indexOf(i);
|
|
idx >= 0 ? target.splice(idx, 1) : target.push(i);
|
|
}
|
|
|
|
/* ── Next step handler (with validation) ── */
|
|
async function wizNext() {
|
|
wizError('');
|
|
|
|
/* ── Step 1 validation ── */
|
|
if (wStepIndex === 0) {
|
|
if (!wMode) { wizError('Vui lòng chọn phương thức'); return; }
|
|
|
|
if (wMode === 'create') {
|
|
const host = document.getElementById('wvc-host').value.trim();
|
|
const user = document.getElementById('wvc-user').value.trim();
|
|
const pass = document.getElementById('wvc-password').value;
|
|
if (!host || !user || !pass) { wizError('Vui lòng nhập đầy đủ thông tin vCenter'); return; }
|
|
try {
|
|
document.getElementById('btn-wiz-next').disabled = true;
|
|
const res = await fetch(WIZ_ROUTES.vcenterStore, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': CSRF, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
host, user, password: pass,
|
|
status: document.getElementById('wvc-status').value,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Lỗi');
|
|
wSelectedVcenterId = data.vcenter.id;
|
|
wVcenters.push(data.vcenter);
|
|
} catch (e) {
|
|
wizError(e.message);
|
|
document.getElementById('btn-wiz-next').disabled = false;
|
|
return;
|
|
}
|
|
document.getElementById('btn-wiz-next').disabled = false;
|
|
} else {
|
|
wSelectedVcenterId = document.getElementById('wvc-select').value;
|
|
if (!wSelectedVcenterId) { wizError('Vui lòng chọn vCenter'); return; }
|
|
}
|
|
}
|
|
|
|
/* ── Step 2 validation ── */
|
|
if (wStepIndex === 1) {
|
|
if (!wSubMode) { wizError('Vui lòng chọn phương thức'); return; }
|
|
|
|
if (wSubMode === 'manual') {
|
|
wManualDc.name = document.getElementById('wdc-name').value.trim();
|
|
wManualDc.datacenter_id = document.getElementById('wdc-id').value.trim();
|
|
wManualHost.name = document.getElementById('whost-name').value.trim();
|
|
wManualHost.host_id = document.getElementById('whost-id').value.trim();
|
|
if (!wManualDc.name || !wManualDc.datacenter_id || !wManualHost.name || !wManualHost.host_id) {
|
|
wizError('Vui lòng nhập đầy đủ thông tin'); return;
|
|
}
|
|
} else {
|
|
wSelectedDatacenterId = document.getElementById('wdc-sync-select').value;
|
|
const hostId = document.getElementById('whost-sync-select').value;
|
|
if (!wSelectedDatacenterId || !hostId) {
|
|
wizError('Vui lòng chọn Datacenter và Host'); return;
|
|
}
|
|
await wizLoadDsNw();
|
|
}
|
|
}
|
|
|
|
wStepper.next();
|
|
}
|
|
|
|
/* ── Save handler ── */
|
|
async function wizSave() {
|
|
wizError('');
|
|
document.getElementById('btn-wiz-save').disabled = true;
|
|
|
|
let datacenter, host, datastores, networks;
|
|
if (wSubMode === 'manual') {
|
|
datacenter = wManualDc;
|
|
host = wManualHost;
|
|
datastores = [];
|
|
networks = [];
|
|
} else {
|
|
datacenter = wDatacenters.find(d => d.datacenter_id == wSelectedDatacenterId);
|
|
host = wHosts.find(h => h.host_id == document.getElementById('whost-sync-select').value);
|
|
datastores = wSelectedDs.map(i => wDatastores[i]);
|
|
networks = wSelectedNw.map(i => wNetworks[i]);
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(WIZ_ROUTES.save, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-TOKEN': CSRF, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ vcenter_id: wSelectedVcenterId, datacenter, host, datastores, networks }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Lỗi');
|
|
bootstrap.Modal.getInstance(document.getElementById('wizardModal')).hide();
|
|
location.reload();
|
|
} catch (e) {
|
|
wizError(e.message);
|
|
document.getElementById('btn-wiz-save').disabled = false;
|
|
}
|
|
}
|
|
|
|
/* ── wizard: mode-card click listeners ── */
|
|
function initModeCards() {
|
|
/* Step 1 cards */
|
|
document.querySelectorAll('#step-vcenter .mode-card').forEach(card => {
|
|
card.addEventListener('click', function () {
|
|
wMode = this.dataset.wizMode;
|
|
document.querySelectorAll('#step-vcenter .mode-card').forEach(c => c.classList.remove('selected'));
|
|
document.querySelectorAll('#wvc-create-section,#wvc-existing-section')
|
|
.forEach(s => s.classList.add('d-none'));
|
|
this.classList.add('selected');
|
|
document.getElementById(this.dataset.wizSection).classList.remove('d-none');
|
|
reloadIcons(document.getElementById(this.dataset.wizSection));
|
|
});
|
|
});
|
|
|
|
/* Step 2 cards */
|
|
document.querySelectorAll('#step-dc-host .mode-card').forEach(card => {
|
|
card.addEventListener('click', function () {
|
|
wSubMode = this.dataset.wizSubmode;
|
|
document.querySelectorAll('#step-dc-host .mode-card').forEach(c => c.classList.remove('selected'));
|
|
document.querySelectorAll('#wdc-manual-section,#wdc-sync-section')
|
|
.forEach(s => s.classList.add('d-none'));
|
|
this.classList.add('selected');
|
|
document.getElementById(this.dataset.wizSection).classList.remove('d-none');
|
|
if (wSubMode === 'sync') wizLoadDatacenters();
|
|
});
|
|
});
|
|
|
|
/* dc select change → load hosts */
|
|
document.getElementById('wdc-sync-select')
|
|
.addEventListener('change', wizLoadHosts);
|
|
}
|
|
|
|
/* ── wizard: modal events ── */
|
|
document.getElementById('wizardModal').addEventListener('show.bs.modal', () => {
|
|
if (!wStepper) {
|
|
wStepper = new Stepper(document.getElementById('addInfraWizard'), {
|
|
linear: false,
|
|
animation: true,
|
|
});
|
|
}
|
|
wizReset();
|
|
wStepper.to(1);
|
|
wStepIndex = 0;
|
|
wizUpdateFooter();
|
|
wizLoadVcenters();
|
|
reloadIcons(document.getElementById('addInfraWizard'));
|
|
});
|
|
|
|
document.getElementById('addInfraWizard').addEventListener('show.bs-stepper', e => {
|
|
wStepIndex = e.detail.indexStep;
|
|
wizUpdateFooter();
|
|
wizError('');
|
|
});
|
|
|
|
document.getElementById('btn-wiz-prev').addEventListener('click', () => { wizError(''); wStepper.previous(); });
|
|
document.getElementById('btn-wiz-next').addEventListener('click', wizNext);
|
|
document.getElementById('btn-wiz-save').addEventListener('click', wizSave);
|
|
|
|
/* ════════════════════════════════════════════════════════
|
|
Init
|
|
════════════════════════════════════════════════════════ */
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initTree();
|
|
initModeCards();
|
|
reloadIcons();
|
|
});
|
|
|
|
/* spin keyframe for any spinner svg */
|
|
const _style = document.createElement('style');
|
|
_style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
|
|
document.head.appendChild(_style);
|
|
</script>
|
|
@endpush
|