diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index 10be87d..e9c5b62 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -450,13 +450,20 @@ class CustomerController extends Controller 'password' => Hash::make($password), ]); - \Illuminate\Support\Facades\Mail::to($customer->email)->send( - new \App\Mail\Customer\PasswordResetByAdmin([ - 'fullname' => $customer->fullname, - 'email' => $customer->email, - 'password' => $password, - ]) - ); + $passwordMail = new \App\Mail\Customer\PasswordResetByAdmin([ + 'fullname' => $customer->fullname, + 'email' => $customer->email, + 'password' => $password, + ]); + + \Illuminate\Support\Facades\Mail::to($customer->email)->send($passwordMail); + + \App\Models\Mail::create([ + 'subject' => $passwordMail->envelope()->subject, + 'to' => $customer->email, + 'customer_id' => $customer->id, + 'message' => $passwordMail->render(), + ]); Log::create([ 'agent' => 'admin', @@ -558,6 +565,15 @@ class CustomerController extends Controller $mailer->send(new ManualCustomerEmail($subject, $message)); + \App\Models\Mail::create([ + 'template_id' => $template->id ?? 0, + 'subject' => $subject, + 'to' => implode(', ', $to), + 'cc' => implode(', ', $cc), + 'customer_id' => $customer->id, + 'message' => $message, + ]); + $admin = Auth::guard('admin')->user(); Log::create([ 'agent' => 'admin', diff --git a/app/Http/Controllers/Api/Admin/EmailLog/EmailLogController.php b/app/Http/Controllers/Api/Admin/EmailLog/EmailLogController.php index acda49e..3bfb13f 100644 --- a/app/Http/Controllers/Api/Admin/EmailLog/EmailLogController.php +++ b/app/Http/Controllers/Api/Admin/EmailLog/EmailLogController.php @@ -15,7 +15,7 @@ final class EmailLogController extends Controller { $limit = min((int) $request->get('limit', $request->get('per_page', 20)), 200); - $query = Mail::query()->orderByDesc('created_at'); + $query = Mail::query()->with(['customer', 'template'])->orderByDesc('created_at'); // sort=field_asc|field_desc if ($sort = $request->get('sort')) { @@ -30,7 +30,7 @@ final class EmailLogController extends Controller } if ($status = $request->get('status')) { - $query->where('status', $status); + $query->where('status', array_search($status, Mail::STATUS_MAP, true) ?: $status); } if ($customerId = $request->get('customer_id')) { $query->where('customer_id', (int) $customerId); @@ -47,7 +47,7 @@ final class EmailLogController extends Controller return response()->json([ 'error' => false, 'data' => [ - 'items' => $paginator->items(), + 'items' => $paginator->getCollection()->map($this->mapLog(...))->all(), 'total' => $paginator->total(), 'per_page' => $paginator->perPage(), 'current_page' => $paginator->currentPage(), @@ -58,12 +58,28 @@ final class EmailLogController extends Controller public function show(int $id): JsonResponse { - $log = Mail::find($id); + $log = Mail::with(['customer', 'template'])->find($id); if (!$log) { return response()->json(['error' => true, 'message' => 'Không tìm thấy email log'], 404); } - return response()->json(['error' => false, 'data' => $log]); + return response()->json(['error' => false, 'data' => $this->mapLog($log)]); + } + + private function mapLog(Mail $log): array + { + return [ + 'id' => $log->id, + 'to_email' => $log->to, + 'to_name' => $log->customer->fullname ?? null, + 'customer_id' => $log->customer_id, + 'subject' => $log->subject, + 'template' => $log->template->name ?? '—', + 'status' => $log->status, + 'error_message' => null, + 'sent_at' => $log->status === 'sent' ? $log->created_at : null, + 'created_at' => $log->created_at, + ]; } } diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php index 4ad5bac..1437a3a 100644 --- a/app/Http/Controllers/Api/NotificationController.php +++ b/app/Http/Controllers/Api/NotificationController.php @@ -13,22 +13,68 @@ class NotificationController extends Controller { $recipient = $this->resolveRecipient($recipientType); - $notifications = Notification::query() + $query = Notification::query() ->where('recipient_type', $recipientType) - ->where('recipient_id', $recipient->id) - ->orderByDesc('created_at') - ->limit(20) - ->get(); + ->where('recipient_id', $recipient->id); + + $unreadCount = (clone $query)->where('is_read', false)->count(); + + if ($request->filled('is_read')) { + $query->where('is_read', $request->boolean('is_read')); + } + + if ($request->filled('search')) { + $search = $request->string('search'); + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('body', 'like', "%{$search}%"); + }); + } + + $query->orderByDesc('created_at'); + + if ($request->filled('page') || $request->filled('per_page')) { + $perPage = (int) $request->integer('per_page', 15); + $paginator = $query->paginate($perPage, ['*'], 'page', (int) $request->integer('page', 1)); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $paginator->items(), + 'total' => $paginator->total(), + 'unread_count' => $unreadCount, + ], + ]); + } + + $notifications = $query->limit(20)->get(); return response()->json([ 'error' => false, 'data' => [ 'items' => $notifications, - 'unread_count' => $notifications->where('is_read', false)->count(), + 'unread_count' => $unreadCount, ], ]); } + public function show(Request $request, string $recipientType, int $id): JsonResponse + { + $recipient = $this->resolveRecipient($recipientType); + + $notification = Notification::query() + ->where('recipient_type', $recipientType) + ->where('recipient_id', $recipient->id) + ->whereKey($id) + ->first(); + + if (!$notification) { + return response()->json(['error' => true, 'message' => 'Notification not found'], 404); + } + + return response()->json(['error' => false, 'data' => $notification]); + } + public function markRead(Request $request, string $recipientType, int $id): JsonResponse { $recipient = $this->resolveRecipient($recipientType); diff --git a/app/Http/Controllers/admin/VCenterManagerController.php b/app/Http/Controllers/admin/VCenterManagerController.php index 830c245..9949629 100644 --- a/app/Http/Controllers/admin/VCenterManagerController.php +++ b/app/Http/Controllers/admin/VCenterManagerController.php @@ -661,6 +661,77 @@ class VCenterManagerController extends Controller ], 400); } + public function stats(Request $request) + { + $vmId = (int) $request->input('vm_id'); + + $vm = VirtualMachines::find($vmId); + + if (!$vm) { + return response()->json(['error' => true, 'message' => 'VM not found'], 404); + } + + $vmwareId = $vm->vm_id; + + if (!$vmwareId) { + return response()->json(['error' => true, 'message' => 'VM VMware ID not found'], 400); + } + + if ($vm->power_state !== 'POWERED_ON') { + return response()->json(['error' => true, 'message' => 'VM is not powered on'], 400); + } + + $vcenter = $vm->datacenter?->vCenter ?? $vm->host?->datacenter?->vCenter; + + if (!$vcenter) { + return response()->json(['error' => true, 'message' => 'vCenter not found'], 400); + } + + $vCenterService = new \App\Http\Services\vCenter(); + + if (!$vCenterService->loginServer($vcenter)) { + return response()->json(['error' => true, 'message' => 'Cannot login to vCenter'], 400); + } + + $result = $vCenterService->GetVMStats($vmwareId); + + if ($result['error'] ?? true) { + return response()->json([ + 'error' => true, + 'message' => $result['message'] ?? 'Failed to fetch VM stats', + ], 400); + } + + $stats = $result['data']; + $cpuPercent = $stats['cpu']['used_percent'] ?? 0; + $memPercent = $stats['memory']['usage_percent'] ?? 0; + + // ponytail: overall_status is a CPU/RAM usage threshold heuristic, not real vCenter + // health/alarms — that needs the SOAP EventManager/AlarmManager, not wired up in this + // codebase (REST Automation API only). Upgrade path: add a SOAP client and read + // triggeredAlarmState off the VM's ManagedEntity if per-alarm detail is ever needed. + $overallStatus = match (true) { + $cpuPercent >= 90 || $memPercent >= 90 => 'red', + $cpuPercent >= 75 || $memPercent >= 75 => 'yellow', + default => 'green', + }; + + return response()->json([ + 'error' => false, + 'data' => [ + 'overall_status' => $overallStatus, + 'cpu_usage_percent' => round($cpuPercent, 1), + 'mem_usage_percent' => round($memPercent, 1), + 'mem_usage_mb' => $stats['memory']['used_MiB'] ?? 0, + 'mem_total_mb' => $stats['memory']['size_MiB'] ?? 0, + // ponytail: no uptime source wired up (would need SOAP PerfManager + // sys.uptime.latest); frontend renders "—" when null. + 'uptime_seconds' => null, + 'alarms' => [], + ], + ]); + } + public function getPoweredOffVms() { $vms = VirtualMachines::whereNotNull('vm_id') diff --git a/app/Http/Services/Telegram.php b/app/Http/Services/Telegram.php index 2fa97b7..7d94096 100644 --- a/app/Http/Services/Telegram.php +++ b/app/Http/Services/Telegram.php @@ -161,6 +161,25 @@ class Telegram return $this->SendMessage($msg); } + /** + * Notify one step of the VM rebuild pipeline (success or failure). + */ + public function SendRebuildStepNotification(string $vmName, string $ip, string $step, bool $success, ?string $note = null) + { + $msg = ($success ? '🔄' : '❌') . " Rebuild VPS — {$step}\n"; + $msg .= "--------------------------------\n"; + $msg .= "🖥️ VM: {$vmName}\n"; + $msg .= "🌐 IP: {$ip}\n"; + $msg .= "📊 Trạng thái: " . ($success ? 'Hoàn tất ✅' : 'Thất bại ❌') . "\n"; + if ($note) { + $msg .= "📝 " . ($success ? 'Ghi chú' : 'Lỗi') . ": {$note}\n"; + } + $msg .= "🕒 Thời gian: " . Carbon::now()->format('d/m/Y H:i:s') . "\n"; + $msg .= "--------------------------------"; + + return $this->SendMessage($msg); + } + /** * Send a photo to Telegram group * @param string $filePath Local storage path (e.g. Upload/2024/06/01/image.png) diff --git a/app/Jobs/Mail/Customer/VmRebuildFailedJob.php b/app/Jobs/Mail/Customer/VmRebuildFailedJob.php new file mode 100644 index 0000000..ab5c90c --- /dev/null +++ b/app/Jobs/Mail/Customer/VmRebuildFailedJob.php @@ -0,0 +1,40 @@ +data = $data; + } + + public function handle(): void + { + if (empty($this->data['customer_email'])) { + return; + } + + try { + Mail::to($this->data['customer_email']) + ->send(new VmRebuildFailed($this->data)); + } catch (\Throwable $e) { + \Log::error('Không thể gửi email thông báo rebuild thất bại: ' . $e->getMessage(), [ + 'customer_email' => $this->data['customer_email'], + 'module_vmware_id' => $this->data['module_vmware_id'] ?? null, + ]); + } + } +} diff --git a/app/Jobs/VirtualMachine/Rebuild/RebuildAddonInstallJob.php b/app/Jobs/VirtualMachine/Rebuild/RebuildAddonInstallJob.php index 186f100..53dcd56 100644 --- a/app/Jobs/VirtualMachine/Rebuild/RebuildAddonInstallJob.php +++ b/app/Jobs/VirtualMachine/Rebuild/RebuildAddonInstallJob.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Jobs\VirtualMachine\Rebuild; +use App\Http\Services\Telegram; +use App\Jobs\Mail\Customer\VmRebuildFailedJob; +use App\Models\ModuleVmware; use App\Services\VirtualMachine\Rebuild\VmRebuildAddonService; use App\Services\VirtualMachine\Rebuild\VmRebuildService; use Illuminate\Bus\Queueable; @@ -62,6 +65,8 @@ final class RebuildAddonInstallJob implements ShouldQueue 'module_vmware_id' => $this->moduleVmwareId, 'new_vm_id' => $this->newVmId, ]); + + (new Telegram)->SendRebuildStepNotification($ctx->vmName, $ctx->ip, 'Cài đặt addon (CPU/RAM/Disk)', true); } public function failed(Throwable $e): void @@ -77,5 +82,22 @@ final class RebuildAddonInstallJob implements ShouldQueue 'error_message' => 'AddonInstall failed: ' . $e->getMessage(), 'updated_at' => now(), ]); + + $vm = ModuleVmware::with('customer')->find($this->moduleVmwareId); + (new Telegram)->SendRebuildStepNotification( + $vm->name ?? "VM #{$this->moduleVmwareId}", + $vm->ip ?? '', + 'Cài đặt addon (CPU/RAM/Disk)', + false, + $e->getMessage(), + ); + + VmRebuildFailedJob::dispatch([ + 'customer_email' => $vm?->customer?->email, + 'customer_name' => $vm?->customer?->fullname, + 'vm_name' => $vm->name ?? "VM #{$this->moduleVmwareId}", + 'ip' => $vm->ip ?? '', + 'module_vmware_id' => $this->moduleVmwareId, + ]); } } diff --git a/app/Jobs/VirtualMachine/Rebuild/RebuildAssignIpJob.php b/app/Jobs/VirtualMachine/Rebuild/RebuildAssignIpJob.php index d55ccee..d124491 100644 --- a/app/Jobs/VirtualMachine/Rebuild/RebuildAssignIpJob.php +++ b/app/Jobs/VirtualMachine/Rebuild/RebuildAssignIpJob.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Jobs\VirtualMachine\Rebuild; +use App\Http\Services\Telegram; +use App\Jobs\Mail\Customer\VmRebuildFailedJob; +use App\Models\ModuleVmware; use App\Services\VirtualMachine\Rebuild\VmRebuildIpService; use App\Services\VirtualMachine\Rebuild\VmRebuildService; use Illuminate\Bus\Queueable; @@ -73,6 +76,8 @@ final class RebuildAssignIpJob implements ShouldQueue 'module_vmware_id' => $this->moduleVmwareId, 'new_vm_id' => $this->newVmId, ]); + + (new Telegram)->SendRebuildStepNotification($ctx->vmName, $ctx->ip, 'Gán địa chỉ IP', true); } public function failed(Throwable $e): void @@ -88,5 +93,22 @@ final class RebuildAssignIpJob implements ShouldQueue 'error_message' => 'AssignIp failed: ' . $e->getMessage(), 'updated_at' => now(), ]); + + $vm = ModuleVmware::with('customer')->find($this->moduleVmwareId); + (new Telegram)->SendRebuildStepNotification( + $vm->name ?? "VM #{$this->moduleVmwareId}", + $vm->ip ?? '', + 'Gán địa chỉ IP', + false, + $e->getMessage(), + ); + + VmRebuildFailedJob::dispatch([ + 'customer_email' => $vm?->customer?->email, + 'customer_name' => $vm?->customer?->fullname, + 'vm_name' => $vm->name ?? "VM #{$this->moduleVmwareId}", + 'ip' => $vm->ip ?? '', + 'module_vmware_id' => $this->moduleVmwareId, + ]); } } diff --git a/app/Jobs/VirtualMachine/Rebuild/RebuildChangePasswordJob.php b/app/Jobs/VirtualMachine/Rebuild/RebuildChangePasswordJob.php index c3f115f..dfd1f50 100644 --- a/app/Jobs/VirtualMachine/Rebuild/RebuildChangePasswordJob.php +++ b/app/Jobs/VirtualMachine/Rebuild/RebuildChangePasswordJob.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Jobs\VirtualMachine\Rebuild; +use App\Http\Services\Telegram; +use App\Jobs\Mail\Customer\VmRebuildFailedJob; +use App\Models\ModuleVmware; use App\Services\VirtualMachine\Rebuild\VmRebuildPasswordService; use App\Services\VirtualMachine\Rebuild\VmRebuildService; use Illuminate\Bus\Queueable; @@ -62,6 +65,15 @@ final class RebuildChangePasswordJob implements ShouldQueue 'module_vmware_id' => $this->moduleVmwareId, 'new_vm_id' => $this->newVmId, ]); + + $vmAfter = ModuleVmware::find($this->moduleVmwareId); + (new Telegram)->SendRebuildStepNotification( + $ctx->vmName, + $ctx->ip, + 'Đổi mật khẩu' . ($ctx->isWindows ? ' & Port RDP' : ''), + true, + $ctx->isWindows ? "Port RDP mới: {$vmAfter?->port}" : null, + ); } public function failed(Throwable $e): void @@ -77,5 +89,22 @@ final class RebuildChangePasswordJob implements ShouldQueue 'error_message' => 'ChangePassword failed: ' . $e->getMessage(), 'updated_at' => now(), ]); + + $vm = ModuleVmware::with('customer')->find($this->moduleVmwareId); + (new Telegram)->SendRebuildStepNotification( + $vm->name ?? "VM #{$this->moduleVmwareId}", + $vm->ip ?? '', + 'Đổi mật khẩu & Port', + false, + $e->getMessage(), + ); + + VmRebuildFailedJob::dispatch([ + 'customer_email' => $vm?->customer?->email, + 'customer_name' => $vm?->customer?->fullname, + 'vm_name' => $vm->name ?? "VM #{$this->moduleVmwareId}", + 'ip' => $vm->ip ?? '', + 'module_vmware_id' => $this->moduleVmwareId, + ]); } } diff --git a/app/Jobs/VirtualMachine/Rebuild/RebuildDeleteOldVmJob.php b/app/Jobs/VirtualMachine/Rebuild/RebuildDeleteOldVmJob.php index 71be7f2..cdd243c 100644 --- a/app/Jobs/VirtualMachine/Rebuild/RebuildDeleteOldVmJob.php +++ b/app/Jobs/VirtualMachine/Rebuild/RebuildDeleteOldVmJob.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Jobs\VirtualMachine\Rebuild; +use App\Http\Services\Telegram; use App\Http\Services\vCenter as VCenterService; use App\Models\ModuleVmware; use App\Models\vCenter as VCenterModel; @@ -82,6 +83,8 @@ final class RebuildDeleteOldVmJob implements ShouldQueue 'old_vm_id' => $this->oldVmId, 'result' => $res, ]); + + (new Telegram)->SendRebuildStepNotification($vm->name ?? '', $vm->ip ?? '', 'Xoá VM cũ', true); } catch (Throwable $e) { Log::error('RebuildDeleteOldVmJob: delete failed', [ 'old_vm_id' => $this->oldVmId, @@ -99,5 +102,14 @@ final class RebuildDeleteOldVmJob implements ShouldQueue 'old_vm_id' => $this->oldVmId, 'error' => $e->getMessage(), ]); + + $vm = ModuleVmware::find($this->moduleVmwareId); + (new Telegram)->SendRebuildStepNotification( + $vm->name ?? "VM #{$this->moduleVmwareId}", + $vm->ip ?? '', + 'Xoá VM cũ', + false, + "Old VM {$this->oldVmId} cần xoá thủ công: " . $e->getMessage(), + ); } } diff --git a/app/Jobs/VirtualMachine/Rebuild/RebuildFinalizeJob.php b/app/Jobs/VirtualMachine/Rebuild/RebuildFinalizeJob.php index 6537884..3e091ee 100644 --- a/app/Jobs/VirtualMachine/Rebuild/RebuildFinalizeJob.php +++ b/app/Jobs/VirtualMachine/Rebuild/RebuildFinalizeJob.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Jobs\VirtualMachine\Rebuild; +use App\Http\Services\Telegram; use App\Jobs\Mail\Admin\AdminOrderNotificationJob; +use App\Jobs\Mail\Customer\VmRebuildFailedJob; use App\Jobs\Mail\SendInformationVirtualMachine; use App\Models\Invoice; use App\Models\ModuleVmware; @@ -94,6 +96,8 @@ final class RebuildFinalizeJob implements ShouldQueue 'new_vm_id' => $this->newVmId, 'old_vm_id' => $oldVmId, ]); + + (new Telegram)->SendRebuildStepNotification($ctx->vmName, $ctx->ip, 'Hoàn tất & chuyển đổi VM', true, 'Rebuild thành công, VM mới đã online.'); } public function failed(Throwable $e): void @@ -109,6 +113,23 @@ final class RebuildFinalizeJob implements ShouldQueue 'error_message' => $e->getMessage(), 'updated_at' => now(), ]); + + $vm = ModuleVmware::with('customer')->find($this->moduleVmwareId); + (new Telegram)->SendRebuildStepNotification( + $vm->name ?? "VM #{$this->moduleVmwareId}", + $vm->ip ?? '', + 'Hoàn tất & chuyển đổi VM', + false, + $e->getMessage(), + ); + + VmRebuildFailedJob::dispatch([ + 'customer_email' => $vm?->customer?->email, + 'customer_name' => $vm?->customer?->fullname, + 'vm_name' => $vm->name ?? "VM #{$this->moduleVmwareId}", + 'ip' => $vm->ip ?? '', + 'module_vmware_id' => $this->moduleVmwareId, + ]); } private function sendCredentialsEmail(int $moduleVmwareId): void diff --git a/app/Jobs/VirtualMachine/Rebuild/RebuildVirtualMachineJob.php b/app/Jobs/VirtualMachine/Rebuild/RebuildVirtualMachineJob.php index 6985a13..ce26da9 100644 --- a/app/Jobs/VirtualMachine/Rebuild/RebuildVirtualMachineJob.php +++ b/app/Jobs/VirtualMachine/Rebuild/RebuildVirtualMachineJob.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Jobs\VirtualMachine\Rebuild; +use App\Http\Services\Telegram; +use App\Jobs\Mail\Customer\VmRebuildFailedJob; use App\Models\ModuleVmware; use App\Models\ReinstallLog; use App\Models\Service; @@ -39,6 +41,8 @@ final class RebuildVirtualMachineJob implements ShouldQueue public int $tries = 1; public int $timeout = 900; + private bool $failureHandled = false; + public function __construct( public readonly int $moduleVmwareId, public readonly int $newOsId, @@ -85,6 +89,8 @@ final class RebuildVirtualMachineJob implements ShouldQueue 'module_vmware_id' => $this->moduleVmwareId, 'new_vm_id' => $newVmId, ]); + + (new Telegram)->SendRebuildStepNotification($ctx->vmName, $ctx->ip, 'Clone VM mới', true); } catch (Throwable $e) { $this->onFailure($e); throw $e; @@ -98,6 +104,11 @@ final class RebuildVirtualMachineJob implements ShouldQueue private function onFailure(Throwable $e): void { + if ($this->failureHandled) { + return; + } + $this->failureHandled = true; + Log::error('RebuildVirtualMachineJob: failed — old VM preserved', [ 'module_vmware_id' => $this->moduleVmwareId, 'error' => $e->getMessage(), @@ -113,6 +124,23 @@ final class RebuildVirtualMachineJob implements ShouldQueue 'status' => ReinstallLog::STATUS_FAILED, 'error_message' => $e->getMessage(), ]); + + $vm = ModuleVmware::with('customer')->find($this->moduleVmwareId); + (new Telegram)->SendRebuildStepNotification( + $vm->name ?? "VM #{$this->moduleVmwareId}", + $vm->ip ?? '', + 'Clone VM mới', + false, + $e->getMessage(), + ); + + VmRebuildFailedJob::dispatch([ + 'customer_email' => $vm?->customer?->email, + 'customer_name' => $vm?->customer?->fullname, + 'vm_name' => $vm->name ?? "VM #{$this->moduleVmwareId}", + 'ip' => $vm->ip ?? '', + 'module_vmware_id' => $this->moduleVmwareId, + ]); } private function markServiceStatus(?int $serviceId, string $status): void diff --git a/app/Mail/Customer/VmRebuildFailed.php b/app/Mail/Customer/VmRebuildFailed.php new file mode 100644 index 0000000..18400fa --- /dev/null +++ b/app/Mail/Customer/VmRebuildFailed.php @@ -0,0 +1,49 @@ +data = $data; + } + + public function envelope(): Envelope + { + return new Envelope( + subject: 'Thông báo: Cài lại hệ điều hành thất bại - ' . ($this->data['vm_name'] ?? 'VPS'), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.customer.vm-rebuild-failed', + with: [ + 'customerName' => $this->data['customer_name'] ?? 'Khách hàng', + 'vmName' => $this->data['vm_name'] ?? 'N/A', + 'ip' => $this->data['ip'] ?? 'N/A', + 'supportEmail' => config('mail.support_email', 'support@example.com'), + 'supportPhone' => config('mail.support_phone', '1900-xxxx'), + 'serviceUrl' => rtrim((string) env('FRONTEND_URL', config('app.url')), '/') . '/dich-vu', + ], + ); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/Mail.php b/app/Models/Mail.php index 855afe6..5ffd87b 100644 --- a/app/Models/Mail.php +++ b/app/Models/Mail.php @@ -20,4 +20,26 @@ class Mail extends Model 'message', 'status' ]; + + // ponytail: status is stored as tinyint (1=sent, 2=failed, 3=pending) but the + // admin API/UI contract expects string enums; map it here so every reader agrees. + public const STATUS_MAP = [1 => 'sent', 2 => 'failed', 3 => 'pending']; + + protected function status(): \Illuminate\Database\Eloquent\Casts\Attribute + { + return \Illuminate\Database\Eloquent\Casts\Attribute::make( + get: fn ($value) => self::STATUS_MAP[$value] ?? self::STATUS_MAP[1], + set: fn ($value) => is_string($value) ? (array_search($value, self::STATUS_MAP, true) ?: 1) : $value, + ); + } + + public function customer() + { + return $this->belongsTo(Customer::class, 'customer_id'); + } + + public function template() + { + return $this->belongsTo(Template::class, 'template_id'); + } } diff --git a/app/Services/VirtualMachine/Rebuild/VmRebuildPasswordService.php b/app/Services/VirtualMachine/Rebuild/VmRebuildPasswordService.php index 03ac3b8..66dc921 100644 --- a/app/Services/VirtualMachine/Rebuild/VmRebuildPasswordService.php +++ b/app/Services/VirtualMachine/Rebuild/VmRebuildPasswordService.php @@ -6,6 +6,7 @@ namespace App\Services\VirtualMachine\Rebuild; use App\DTOs\VirtualMachine\Rebuild\RebuildContext; use App\Http\Facade\SignRSA; +use App\Http\Services\vCenter as VCenterService; use App\Models\ModuleVmware; use App\Models\ReinstallLog; use App\Models\vCenter as VCenterModel; @@ -25,8 +26,10 @@ use RuntimeException; */ final class VmRebuildPasswordService { - public function __construct(private readonly VsphereHostSshService $ssh) - { + public function __construct( + private readonly VsphereHostSshService $ssh, + private readonly VCenterService $vCenter, + ) { } /** @@ -97,9 +100,44 @@ final class VmRebuildPasswordService 'reinstall_log_id' => $log?->id, ]); + // Windows RDP port is rotated together with the password — a fresh + // random port each rebuild. Linux keeps port 22 (no remote SSH-port + // change script exists on the jump host yet). + if ($ctx->isWindows) { + $this->rotatePort($ctx, $newPassword); + } + return $newPassword; } + private function rotatePort(RebuildContext $ctx, string $guestPassword): void + { + $newPort = (string) random_int(10000, 60000); + + $result = $this->vCenter->changeRDPPortWindows( + $ctx->transientName, + $ctx->guestUser, + $guestPassword, + $newPort, + ); + + $status = is_array($result) ? strtolower((string) ($result['status'] ?? '')) : ''; + if ($status !== 'ok') { + throw new RuntimeException( + 'Rebuild port: changeRDPPortWindows failed — ' . (is_string($result) ? $result : json_encode($result)), + ); + } + + ModuleVmware::where('id', $ctx->moduleVmwareId)->update([ + 'port' => (int) ($result['newPort'] ?? $newPort), + ]); + + Log::info('VmRebuildPasswordService: RDP port rotated', [ + 'module_vmware_id' => $ctx->moduleVmwareId, + 'new_port' => $result['newPort'] ?? $newPort, + ]); + } + private function generatePassword(int $length): string { $sets = [ diff --git a/app/Services/VirtualMachine/Rebuild/VmRebuildService.php b/app/Services/VirtualMachine/Rebuild/VmRebuildService.php index c01767c..ba88fda 100644 --- a/app/Services/VirtualMachine/Rebuild/VmRebuildService.php +++ b/app/Services/VirtualMachine/Rebuild/VmRebuildService.php @@ -6,9 +6,12 @@ namespace App\Services\VirtualMachine\Rebuild; use App\DTOs\VirtualMachine\Rebuild\RebuildContext; use App\Http\Facade\SignRSA; +use App\Http\Services\Telegram; use App\Jobs\VirtualMachine\Rebuild\RebuildVirtualMachineJob; +use App\Models\Customer; use App\Models\ModuleHost; use App\Models\ModuleVmware; +use App\Models\Notification; use App\Models\Os; use App\Models\ReinstallLog; use App\Models\Service; @@ -189,6 +192,28 @@ final class VmRebuildService RebuildVirtualMachineJob::dispatch($ctx->moduleVmwareId, $ctx->newOsId, $log->id) ->onQueue('default'); + if ($initiator === 'client') { + $customerName = Customer::find($initiatorCustomerId)?->fullname ?? "Khách hàng #{$initiatorCustomerId}"; + Notification::notifyAdmins([ + 'type' => 'vps_rebuild_requested', + 'title' => 'Khách hàng vừa yêu cầu rebuild VPS', + 'body' => "{$customerName} vừa rebuild VPS {$ctx->vmName} ({$ctx->ip})", + 'link' => '/rebuild-logs', + 'data' => [ + 'module_vmware_id' => $ctx->moduleVmwareId, + 'reinstall_log_id' => $log->id, + ], + ]); + } + + (new Telegram)->SendRebuildStepNotification( + $ctx->vmName, + $ctx->ip, + 'Bắt đầu rebuild', + true, + "Khởi tạo bởi: {$initiator}", + ); + return $log; } diff --git a/database/migrations/2026_07_17_000000_add_customer_foreign_key_to_mail_table.php b/database/migrations/2026_07_17_000000_add_customer_foreign_key_to_mail_table.php new file mode 100644 index 0000000..e116f3f --- /dev/null +++ b/database/migrations/2026_07_17_000000_add_customer_foreign_key_to_mail_table.php @@ -0,0 +1,30 @@ +where('customer_id', 0)->update(['customer_id' => null]); + + // ponytail: raw SQL instead of Blueprint::change() to avoid adding doctrine/dbal just for this + DB::statement('ALTER TABLE `mail` MODIFY `customer_id` BIGINT UNSIGNED NULL DEFAULT NULL'); + + Schema::table('mail', function (Blueprint $table) { + $table->foreign('customer_id')->references('id')->on('customers')->nullOnDelete(); + }); + } + + public function down() + { + Schema::table('mail', function (Blueprint $table) { + $table->dropForeign(['customer_id']); + }); + + DB::statement('ALTER TABLE `mail` MODIFY `customer_id` BIGINT NOT NULL DEFAULT 0'); + } +}; diff --git a/resources/views/emails/customer/vm-rebuild-failed.blade.php b/resources/views/emails/customer/vm-rebuild-failed.blade.php new file mode 100644 index 0000000..7ce2784 --- /dev/null +++ b/resources/views/emails/customer/vm-rebuild-failed.blade.php @@ -0,0 +1,121 @@ +@php + $appName = $appName ?? config('app.name', 'Thế Giới Số'); + $fullName = $customerName ?? 'Quý khách'; + $vmName = $vmName ?? 'N/A'; + $ip = $ip ?? 'N/A'; + $serviceUrl = $serviceUrl ?? rtrim((string) env('FRONTEND_URL', config('app.url')), '/') . '/dich-vu'; + $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; + $supportPhone = $supportPhone ?? '1900. 6119'; +@endphp + + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + +
+
⚠️
+

+ Thông báo về VPS {{ $vmName }} +

+
+ Đội ngũ kỹ thuật đang xử lý yêu cầu của Quý khách +
+
+ + + + + + +
+
 
+
Xin chào {{ $fullName }},
+
 
+
+ Rất tiếc, yêu cầu cài lại hệ điều hành (rebuild) cho VPS của Quý khách hiện chưa thực hiện thành công. Đội ngũ kỹ thuật của {{ $appName }} đã được thông báo để kiểm tra và xử lý. +
+
 
+
Thông tin dịch vụ:
+
    + Tên VPS: {{ $vmName }}
+
    + IP: {{ $ip }}
+
 
+
+ VPS hiện tại của Quý khách vẫn đang hoạt động bình thường trên cấu hình cũ, không có dữ liệu hay dịch vụ nào bị gián đoạn. Quý khách không cần thực hiện thêm thao tác nào, chúng tôi sẽ liên hệ lại khi rebuild được xử lý xong. +
+
 
+ +
 
+
+ Nếu cần hỗ trợ khẩn cấp, Quý khách vui lòng liên hệ qua email {{ $supportEmail }} hoặc tổng đài {{ $supportPhone }}. +
+
 
+
+ Email này được gửi tự động. Vui lòng không trả lời email này. +
+
 
+
+
+
+ + + + + + + + + + + + +
 
+

+ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
+ Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh
+ Tel: (028) 7309. 7379 - Tổng đài: {{ $supportPhone }}
+ Email: {{ $supportEmail }}
+ + www.TGS.com.vn & + www.thegioiso.CLOUD & + www.thegioiSERVER.com & + www.CloudEmail.vn + +

+

+ + + + 🥇Thế Giới Số🥇- Premium HOSTING • CLOUD • SERVER & WEBSITE | 15+ năm Phụng Sự 🚀 + + + +

+
 
+
+
diff --git a/routes/api.php b/routes/api.php index 33dcb95..5173aff 100644 --- a/routes/api.php +++ b/routes/api.php @@ -598,6 +598,7 @@ Route::prefix('v1') Route::post('vm/suspend', 'suspend')->middleware('perm:module-vcenter.edit'); Route::post('vm/update-config', 'updateConfig')->middleware('perm:module-vcenter.edit'); Route::post('vm/delete', 'deleteVM')->middleware('perm:module-vcenter.edit'); + Route::get('vm/stats', 'stats')->middleware('perm:module-vcenter.view'); Route::get('vms/powered-off', 'getPoweredOffVms')->middleware('perm:module-vcenter.view'); }); @@ -615,6 +616,10 @@ Route::prefix('v1') ->controller(NotificationController::class) ->group(function () { Route::get('/', fn() => app(NotificationController::class)->index(request(), 'admin')); + Route::get( + '{id}', + fn($id) => app(NotificationController::class)->show(request(), 'admin', (int) $id), + )->whereNumber('id'); Route::patch( '{id}/read', fn($id) => app(NotificationController::class)->markRead(request(), 'admin', (int) $id),