feat: Implement new admin API endpoints for product, customer, vCenter, and authentication, and add vCenter resource management UI.

This commit is contained in:
pbao9
2026-03-05 14:30:31 +07:00
parent 68540009c0
commit cca4e0b86f
47 changed files with 3538 additions and 1086 deletions
@@ -0,0 +1,916 @@
@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 &amp; 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 &amp; 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
@@ -0,0 +1,84 @@
{{-- Detail: Datacenter --}}
<div class="node-header">
<div class="node-avatar bg-warning bg-opacity-10">
<i data-lucide="database" class="text-warning"></i>
</div>
<div class="flex-1">
<p class="node-title">{{ $item->name }}</p>
<p class="node-sub">Datacenter · ID: <code>{{ $item->datacenter_id }}</code></p>
</div>
</div>
<div class="stat-strip">
<div class="stat-card">
<span class="stat-val text-info">{{ $hosts->count() }}</span>
<span class="stat-lbl">Hosts</span>
</div>
<div class="stat-card">
<span class="stat-val text-secondary">{{ $folders->count() }}</span>
<span class="stat-lbl">Folders</span>
</div>
</div>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-hosts">
<i data-lucide="server" style="width:14px;height:14px"></i>
Hosts ({{ $hosts->count() }})
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-folders">
<i data-lucide="folder" style="width:14px;height:14px"></i>
Folders ({{ $folders->count() }})
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-vms">
<i data-lucide="monitor" style="width:14px;height:14px"></i>
Virtual Machines
</button>
</li>
</ul>
<div class="tab-content border border-top-0 rounded-bottom p-3">
{{-- Hosts tab --}}
<div class="tab-pane fade show active" id="tab-hosts">
@forelse ($hosts as $host)
<div class="d-flex align-items-center gap-3 py-2 border-bottom">
<i data-lucide="server" style="width:16px;height:16px" class="text-info flex-shrink-0"></i>
<div class="flex-1">
<div class="fw-semibold">{{ $host->name }}</div>
<small class="text-muted">
{{ $host->datastore_count ?? 0 }} Datastores ·
{{ $host->network_count ?? 0 }} Networks
@if ($host->template) · <span class="badge bg-secondary">Template</span> @endif
</small>
</div>
</div>
@empty
<p class="text-muted mb-0">No hosts configured in this datacenter.</p>
@endforelse
</div>
{{-- Folders tab --}}
<div class="tab-pane fade" id="tab-folders">
@forelse ($folders as $folder)
<div class="d-flex align-items-center gap-3 py-2 border-bottom">
<i data-lucide="folder" style="width:16px;height:16px" class="text-secondary flex-shrink-0"></i>
<div class="flex-1">
<div class="fw-semibold">{{ $folder->name }}</div>
<small class="text-muted font-monospace">{{ $folder->folder }}</small>
</div>
<span class="badge bg-light text-dark border">{{ $folder->type }}</span>
</div>
@empty
<p class="text-muted mb-0">No folders in this datacenter.</p>
@endforelse
</div>
{{-- VMs tab --}}
<div class="tab-pane fade" id="tab-vms">
<table id="vm-datatable" class="table table-sm table-hover w-100"></table>
</div>
</div>
@@ -0,0 +1,29 @@
{{-- Detail: Datastore --}}
<div class="node-header">
<div class="node-avatar bg-success bg-opacity-10">
<i data-lucide="hard-drive" class="text-success"></i>
</div>
<div class="flex-1">
<p class="node-title">{{ $item->name }}</p>
<p class="node-sub">Datastore · <code>{{ $item->datastore }}</code></p>
</div>
<div>{!! $item->status == 1 ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>' !!}</div>
</div>
<div class="card border shadow-none mb-0">
<div class="card-header py-2 small fw-semibold bg-transparent d-flex align-items-center gap-2">
<i data-lucide="settings" style="width:14px;height:14px"></i> Properties
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
<tr><th class="ps-3" style="width:140px">Name</th><td>{{ $item->name }}</td></tr>
<tr><th class="ps-3">Datastore ID</th><td class="font-monospace">{{ $item->datastore }}</td></tr>
<tr><th class="ps-3">Type</th><td><span class="badge bg-light text-dark border">{{ $item->type }}</span></td></tr>
<tr><th class="ps-3">Allocated Space</th><td>{{ $item->space ? number_format($item->space) . ' GB' : '—' }}</td></tr>
<tr><th class="ps-3">Status</th><td>{!! $item->status == 1 ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>' !!}</td></tr>
<tr><th class="ps-3">Created</th><td>{{ $item->created_at?->format('d/m/Y H:i') ?? '—' }}</td></tr>
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,144 @@
{{-- Detail: Host --}}
<div class="node-header">
<div class="node-avatar bg-info bg-opacity-10">
<i data-lucide="server" class="text-info"></i>
</div>
<div class="flex-1">
<p class="node-title">{{ $item->name }}</p>
<p class="node-sub">
ESXi Host · ID: <code>{{ $item->host_id }}</code>
@if ($item->template)
<span class="badge bg-secondary ms-1">Template Host</span>
@endif
</p>
</div>
</div>
<div class="stat-strip">
<div class="stat-card">
<span class="stat-val text-success">{{ $datastores->count() }}</span>
<span class="stat-lbl">Datastores</span>
</div>
<div class="stat-card">
<span class="stat-val text-warning">{{ $networks->count() }}</span>
<span class="stat-lbl">Networks</span>
</div>
<div class="stat-card">
<span class="stat-val text-primary">{{ $ipTotal }}</span>
<span class="stat-lbl">Total IPs</span>
</div>
<div class="stat-card">
<span class="stat-val text-success">{{ $ipFree }}</span>
<span class="stat-lbl">Free IPs</span>
</div>
<div class="stat-card">
<span class="stat-val text-danger">{{ $ipTotal - $ipFree }}</span>
<span class="stat-lbl">Used IPs</span>
</div>
</div>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-summary">
<i data-lucide="info" style="width:14px;height:14px"></i>
Summary
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-datastores">
<i data-lucide="hard-drive" style="width:14px;height:14px"></i>
Datastores ({{ $datastores->count() }})
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-networks">
<i data-lucide="network" style="width:14px;height:14px"></i>
Networks ({{ $networks->count() }})
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-vms">
<i data-lucide="monitor" style="width:14px;height:14px"></i>
Virtual Machines
</button>
</li>
</ul>
<div class="tab-content border border-top-0 rounded-bottom p-3">
{{-- Summary tab --}}
<div class="tab-pane fade show active" id="tab-summary">
<div class="card border shadow-none mb-0">
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
<tr><th class="ps-3" style="width:140px">Host ID</th><td class="font-monospace">{{ $item->host_id }}</td></tr>
<tr><th class="ps-3">Console</th><td>{{ $item->console ?? '—' }}</td></tr>
<tr><th class="ps-3">ISO Path</th><td class="font-monospace small">{{ $item->iso_path ?? '—' }}</td></tr>
<tr><th class="ps-3">Template Host</th><td>{{ $item->template ? 'Yes' : 'No' }}</td></tr>
<tr><th class="ps-3">Created</th><td>{{ $item->created_at?->format('d/m/Y H:i') ?? '—' }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{{-- Datastores tab --}}
<div class="tab-pane fade" id="tab-datastores">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Datastore ID</th>
<th>Type</th>
<th>Space</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@forelse ($datastores as $ds)
<tr>
<td class="fw-semibold">{{ $ds->name }}</td>
<td class="font-monospace small">{{ $ds->datastore }}</td>
<td><span class="badge bg-light text-dark border">{{ $ds->type }}</span></td>
<td>{{ $ds->space ? number_format($ds->space) . ' GB' : '—' }}</td>
<td>{!! $ds->status == 1 ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>' !!}</td>
</tr>
@empty
<tr><td colspan="5" class="text-center text-muted">No datastores.</td></tr>
@endforelse
</tbody>
</table>
</div>
{{-- Networks tab --}}
<div class="tab-pane fade" id="tab-networks">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Network ID</th>
<th>Type</th>
<th>IPs</th>
</tr>
</thead>
<tbody>
@forelse ($networks as $net)
<tr>
<td class="fw-semibold">{{ $net->name }}</td>
<td class="font-monospace small">{{ $net->network }}</td>
<td><span class="badge bg-light text-dark border">{{ $net->type }}</span></td>
<td>{{ $net->ip_addresses_count ?? 0 }}</td>
</tr>
@empty
<tr><td colspan="4" class="text-center text-muted">No networks.</td></tr>
@endforelse
</tbody>
</table>
</div>
{{-- VMs tab --}}
<div class="tab-pane fade" id="tab-vms">
<table id="vm-datatable" class="table table-sm table-hover w-100"></table>
</div>
</div>
@@ -0,0 +1,81 @@
{{-- Detail: Network --}}
<div class="node-header">
<div class="node-avatar bg-warning bg-opacity-10">
<i data-lucide="network" class="text-warning"></i>
</div>
<div class="flex-1">
<p class="node-title">{{ $item->name }}</p>
<p class="node-sub">Network · <code>{{ $item->network }}</code></p>
</div>
<div><span class="badge bg-light text-dark border">{{ $item->type }}</span></div>
</div>
@php
$total = $ips->count();
$free = $ips->where('status', 0)->count();
$assigned = $ips->where('status', 1)->count();
@endphp
<div class="stat-strip">
<div class="stat-card">
<span class="stat-val">{{ $total }}</span>
<span class="stat-lbl">Total IPs</span>
</div>
<div class="stat-card">
<span class="stat-val text-success">{{ $free }}</span>
<span class="stat-lbl">Free</span>
</div>
<div class="stat-card">
<span class="stat-val text-danger">{{ $assigned }}</span>
<span class="stat-lbl">Assigned</span>
</div>
</div>
<div class="card border shadow-none mb-0">
<div class="card-header py-2 fw-semibold small bg-transparent d-flex align-items-center gap-2">
<i data-lucide="list" style="width:14px;height:14px"></i>
<span>IP Pool</span>
@if ($total)
<div class="ms-auto" style="width:160px; height:8px; background:var(--bs-secondary-bg); border-radius:4px; overflow:hidden;">
<div style="width:{{ round($assigned / $total * 100) }}%; height:100%; background:#dc3545;"></div>
</div>
<small class="ms-2 text-muted">{{ round($assigned / $total * 100) }}% used</small>
@endif
</div>
<div class="card-body p-0">
<div style="max-height:400px; overflow-y:auto;">
<table class="table table-sm table-hover mb-0">
<thead class="table-light sticky-top">
<tr>
<th class="ps-3">IP Address</th>
<th>Gateway</th>
<th>Netmask</th>
<th>DNS</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@forelse ($ips as $ip)
<tr>
<td class="ps-3 font-monospace fw-semibold">{{ $ip->ip }}</td>
<td class="font-monospace small">{{ $ip->gateway ?? '—' }}</td>
<td class="font-monospace small">{{ $ip->netmask ?? '—' }}</td>
<td class="font-monospace small">{{ $ip->dns ?? '—' }}</td>
<td>
@if ($ip->status == 0)
<span class="badge bg-success">Free</span>
@else
<span class="badge bg-danger">Assigned</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center text-muted py-3">No IP addresses in this network.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,52 @@
{{-- Detail: vCenter --}}
<div class="node-header">
<div class="node-avatar bg-primary bg-opacity-10">
<i data-lucide="cloud" class="text-primary"></i>
</div>
<div class="flex-1">
<p class="node-title">{{ $item->host }}</p>
<p class="node-sub">vCenter Server</p>
</div>
<div>{!! $item->getStatus() !!}</div>
</div>
<div class="stat-strip">
<div class="stat-card">
<span class="stat-val text-warning">{{ $datacenterCount }}</span>
<span class="stat-lbl">Datacenters</span>
</div>
<div class="stat-card">
<span class="stat-val text-primary">{{ $vmCount }}</span>
<span class="stat-lbl">Virtual Machines</span>
</div>
</div>
<div class="card border shadow-none mb-3">
<div class="card-header py-2 small fw-semibold bg-transparent d-flex align-items-center gap-2">
<i data-lucide="link" style="width:14px;height:14px"></i> Connection
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
<tr><th class="ps-3" style="width:130px">Host</th><td class="font-monospace">{{ $item->host }}</td></tr>
<tr><th class="ps-3">User</th><td class="font-monospace">{{ $item->user }}</td></tr>
<tr><th class="ps-3">Status</th><td>{!! $item->getStatus() !!}</td></tr>
<tr><th class="ps-3">Created</th><td>{{ $item->created_at?->format('d/m/Y H:i') ?? '—' }}</td></tr>
</tbody>
</table>
</div>
</div>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-vms">
<i data-lucide="monitor" style="width:14px;height:14px"></i>
Virtual Machines
</button>
</li>
</ul>
<div class="tab-content border border-top-0 rounded-bottom p-3">
<div class="tab-pane fade show active" id="tab-vms">
<table id="vm-datatable" class="table table-sm table-hover w-100"></table>
</div>
</div>