feat: Implement new admin API endpoints for product, customer, vCenter, and authentication, and add vCenter resource management UI.
This commit is contained in:
@@ -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 & 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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user