diff --git a/app/Http/Controllers/Panel/AdminController.php b/app/Http/Controllers/Panel/AdminController.php index bac261ff..c0d143b7 100644 --- a/app/Http/Controllers/Panel/AdminController.php +++ b/app/Http/Controllers/Panel/AdminController.php @@ -1,5611 +1,41 @@ -whereNotNull('service_type') - ->whereNotNull('username') - ->count(); - - $onlineCustomers = User::where('is_subscriber', true) - ->whereNotNull('service_type') - ->whereNotNull('username') - ->whereIn('username', function ($subQuery) { - $subQuery->select('username') - ->distinct() - ->from('radius.radacct') - ->whereNull('acctstoptime'); - }) - ->count(); - - // Calculate offline as total minus online for better performance - // Note: This includes customers who have never connected (no radacct entries) - $offlineCustomers = $totalNetworkCustomers - $onlineCustomers; - } catch (\Illuminate\Database\QueryException $e) { - // Swallow "table not found" (42S02) and "permission denied" (42000) errors - $sqlState = $e->getCode(); - $isTableNotFound = $sqlState === '42S02' || str_contains($e->getMessage(), '42S02'); - $isPermissionDenied = $sqlState === '42000' || str_contains($e->getMessage(), '42000'); - - if ($isTableNotFound || $isPermissionDenied) { - $reason = $isTableNotFound ? 'table not found' : 'permission denied'; - Log::warning("Unable to query radacct table for online/offline status ({$reason})", [ - 'sql_state' => $sqlState, - 'error' => $e->getMessage(), - ]); - $onlineCustomers = 0; - $offlineCustomers = 0; - } else { - Log::error('Database error while querying radacct table for online/offline status', [ - 'sql_state' => $sqlState, - 'error' => $e->getMessage(), - ]); - throw $e; - } - } - - $stats = [ - 'total_users' => User::whereDoesntHave('roles', function ($query) use ($excludedRoleSlugs) { - $query->whereIn('slug', $excludedRoleSlugs); - })->count(), - // Count customers with network service types (previously NetworkUser count) - 'total_network_users' => User::where('is_subscriber', true) - ->whereNotNull('service_type') - ->count(), - 'active_users' => User::where('is_active', true) - ->whereDoesntHave('roles', function ($query) use ($excludedRoleSlugs) { - $query->whereIn('slug', $excludedRoleSlugs); - })->count(), - 'total_packages' => ServicePackage::count(), - 'total_mikrotik' => MikrotikRouter::count(), - 'total_nas' => Nas::count(), - 'total_cisco' => CiscoDevice::count(), - 'total_olt' => Olt::count(), - // Billing statistics - 'billed_customers' => Invoice::distinct('user_id')->count('user_id'), - 'total_invoices' => Invoice::count(), - 'total_billed_amount' => Invoice::sum('total_amount'), - 'paid_invoices' => Invoice::where('status', 'paid')->count(), - 'unpaid_invoices' => Invoice::where('status', 'unpaid')->count(), - 'overdue_invoices' => Invoice::where('status', 'overdue')->count(), - // Today's Update statistics - 'new_customers_today' => User::whereDate('created_at', today()) - ->where('is_subscriber', true) - ->count(), - 'payments_today' => Payment::whereDate('payment_date', today()) - ->where('status', 'success') - ->sum('amount'), - 'tickets_today' => \App\Models\Ticket::whereDate('created_at', today())->count(), - // Customers expiring today (now using User model) - 'expiring_today' => User::where('is_subscriber', true) - ->whereDate('expiry_date', today()) - ->count(), - // Additional customer statistics (now using User model) - 'online_customers' => $onlineCustomers, - 'offline_customers' => $offlineCustomers, - 'suspended_customers' => User::where('is_subscriber', true) - ->where('status', 'suspended') - ->count(), - 'pppoe_customers' => User::where('is_subscriber', true) - ->where('service_type', 'pppoe') - ->count(), - 'hotspot_customers' => User::where('is_subscriber', true) - ->where('service_type', 'hotspot') - ->count(), - ]; - - // Task 18: Dashboard Enhancements - Add widget data - - // Task 18.1: Overall status distribution - $statusDistribution = collect(); - if (class_exists(\App\Enums\CustomerOverallStatus::class)) { - $customers = User::where('is_subscriber', true)->get(); - foreach ($customers as $customer) { - $status = $customer->overall_status; - if ($status) { - $statusKey = $status->value; - $statusDistribution[$statusKey] = ($statusDistribution[$statusKey] ?? 0) + 1; - } - } - } - - // Task 18.2: Expiring customers (next 7 days) - $expiringCustomers = User::where('is_subscriber', true) - ->whereNotNull('expiry_date') - ->whereDate('expiry_date', '>=', now()) - ->whereDate('expiry_date', '<=', now()->addDays(7)) - ->with('package', 'billingProfile') - ->orderBy('expiry_date', 'asc') - ->limit(10) - ->get(); - - // Task 18.3: Low-performing packages (fewer than 5 customers) - $lowPerformingPackages = Package::withCount('users') - ->having('users_count', '<', 5) - ->orderBy('users_count', 'asc') - ->limit(10) - ->get(); - - // Task 18.4: Payment collection statistics - $paymentStats = [ - 'total_billed' => $stats['total_billed_amount'] ?? 0, - 'total_collected' => Payment::where('status', 'success')->sum('amount'), - 'total_due' => Invoice::where('status', 'unpaid')->sum('total_amount') + - Invoice::where('status', 'overdue')->sum('total_amount'), - 'customers_paid' => Payment::whereDate('payment_date', '>=', now()->startOfMonth()) - ->where('status', 'success') - ->distinct('user_id') - ->count('user_id'), - 'customers_unpaid' => Invoice::where('status', 'unpaid') - ->orWhere('status', 'overdue') - ->distinct('user_id') - ->count('user_id'), - ]; - - // Operator Performance Data - Optimized to avoid N+1 queries - $operators = User::whereIn('operator_level', [30, 40])->get(); // Operators and Sub-operators - - if ($operators->isNotEmpty()) { - $operatorIds = $operators->pluck('id'); - - // Bulk fetch all customers for all operators using created_by relationship - // FIXED: Use is_subscriber instead of deprecated operator_level - $allCustomers = User::whereIn('created_by', $operatorIds) - ->where('is_subscriber', true) - ->select('id', 'created_by', 'status', 'created_at') - ->get() - ->groupBy('created_by'); - - // Bulk fetch payments for all operator customers - $allCustomerIds = $allCustomers->flatten()->pluck('id'); - $payments = Payment::whereIn('user_id', $allCustomerIds) - ->whereDate('payment_date', '>=', now()->startOfMonth()) - ->where('status', 'success') - ->select('user_id', 'amount') - ->get() - ->groupBy('user_id'); - - // Bulk fetch tickets for all operator customers - $tickets = Ticket::whereIn('customer_id', $allCustomerIds) - ->where('status', 'resolved') - ->whereDate('updated_at', '>=', now()->startOfMonth()) - ->select('customer_id', 'id') - ->get() - ->groupBy('customer_id'); - - $operatorPerformance = $operators->map(function ($operator) use ($allCustomers, $payments, $tickets) { - $operatorCustomers = $allCustomers->get($operator->id, collect()); - $operatorCustomerIds = $operatorCustomers->pluck('id'); - - // Calculate total revenue for this operator's customers - $monthlyRevenue = $operatorCustomerIds->sum(function ($customerId) use ($payments) { - return $payments->get($customerId, collect())->sum('amount'); - }); - - // Count tickets resolved for this operator's customers - $ticketsResolved = $operatorCustomerIds->sum(function ($customerId) use ($tickets) { - return $tickets->get($customerId, collect())->count(); - }); - - return [ - 'id' => $operator->id, - 'name' => $operator->name, - 'operator_level' => $operator->operator_level, - 'total_customers' => $operatorCustomers->count(), - 'active_customers' => $operatorCustomers->where('status', 'active')->count(), - 'monthly_revenue' => $monthlyRevenue, - 'tickets_resolved' => $ticketsResolved, - 'new_customers_this_month' => $operatorCustomers - ->where('created_at', '>=', now()->startOfMonth()) - ->count(), - ]; - }) - ->sortByDesc('monthly_revenue') - ->take(10); - } else { - $operatorPerformance = collect(); - } - - // Revenue Trend Data (last 6 months) - $revenueTrend = collect(); - for ($i = 5; $i >= 0; $i--) { - $month = now()->subMonths($i); - $revenueTrend->push([ - 'month' => $month->format('M Y'), - 'revenue' => Payment::whereYear('payment_date', $month->year) - ->whereMonth('payment_date', $month->month) - ->where('status', 'success') - ->sum('amount'), - ]); - } - - // Customer Growth Data (last 6 months) - $customerGrowth = collect(); - for ($i = 5; $i >= 0; $i--) { - $month = now()->subMonths($i); - $customerGrowth->push([ - 'month' => $month->format('M Y'), - 'customers' => User::where('is_subscriber', true) - ->whereDate('created_at', '<=', $month->endOfMonth()) - ->count(), - ]); - } - - // ISP Information (Admin level 20) - // FIXED: Use is_subscriber instead of deprecated operator_level - $ispInfo = [ - 'status' => 'active', - 'total_clients' => User::where('is_subscriber', true)->count(), - 'active_clients' => User::where('is_subscriber', true) - ->where('status', 'active') - ->count(), - 'inactive_clients' => User::where('is_subscriber', true) - ->where('status', 'inactive') - ->count(), - 'expired_clients' => User::where('is_subscriber', true) - ->where('status', 'expired') - ->count(), - ]; - - // Operator Information (Only Operator level 30, NOT Sub-Operator level 40) - // Fixed to show only Operators, not Sub-Operators - $operatorInfo = [ - 'total' => User::where('operator_level', User::OPERATOR_LEVEL_OPERATOR)->count(), - 'active' => User::where('operator_level', User::OPERATOR_LEVEL_OPERATOR) - ->where('is_active', true) - ->count(), - 'inactive' => User::where('operator_level', User::OPERATOR_LEVEL_OPERATOR) - ->where('is_active', false) - ->count(), - ]; - - // Clients of Operator (customers created by operators AND sub-operators - both levels 30 and 40) - // Using subquery instead of pluck for better performance with large datasets - // Note: Only counts customers with non-null created_by field (i.e., created by operators/sub-operators) - // Customers created directly by admin or with null created_by are not included in these statistics - // FIXED: Use is_subscriber instead of deprecated operator_level - $operatorClients = [ - 'total_clients' => User::where('is_subscriber', true) - ->whereIn('created_by', function ($query) { - $query->select('id') - ->from('users') - ->whereIn('operator_level', [User::OPERATOR_LEVEL_OPERATOR, User::OPERATOR_LEVEL_SUB_OPERATOR]); - }) - ->count(), - 'active_clients' => User::where('is_subscriber', true) - ->whereIn('created_by', function ($query) { - $query->select('id') - ->from('users') - ->whereIn('operator_level', [User::OPERATOR_LEVEL_OPERATOR, User::OPERATOR_LEVEL_SUB_OPERATOR]); - }) - ->where('status', 'active') - ->count(), - 'inactive_clients' => User::where('is_subscriber', true) - ->whereIn('created_by', function ($query) { - $query->select('id') - ->from('users') - ->whereIn('operator_level', [User::OPERATOR_LEVEL_OPERATOR, User::OPERATOR_LEVEL_SUB_OPERATOR]); - }) - ->where('status', 'inactive') - ->count(), - 'expired_clients' => User::where('is_subscriber', true) - ->whereIn('created_by', function ($query) { - $query->select('id') - ->from('users') - ->whereIn('operator_level', [User::OPERATOR_LEVEL_OPERATOR, User::OPERATOR_LEVEL_SUB_OPERATOR]); - }) - ->where('status', 'expired') - ->count(), - ]; - - // Helper function to calculate current MRC for a set of customers - // FIXED: Use is_subscriber instead of deprecated operator_level - $calculateCurrentMRC = function ($whereInCallback = null) { - $query = User::where('is_subscriber', true) - ->whereNotNull('service_package_id'); - - if ($whereInCallback) { - $query->whereIn('created_by', $whereInCallback); - } - - return $query->join('packages', 'users.service_package_id', '=', 'packages.id') - ->where('users.status', 'active') - ->sum('packages.price') ?? 0; - }; - - // Helper function to calculate monthly average MRC from invoices - // FIXED: Use is_subscriber instead of deprecated operator_level - $calculateMonthlyAvgMRC = function ($year, $month, $whereInCallback = null) { - $query = Invoice::whereIn('user_id', function ($subQuery) use ($whereInCallback) { - $subQuery->select('id') - ->from('users') - ->where('is_subscriber', true); - - if ($whereInCallback) { - $subQuery->whereIn('created_by', $whereInCallback); - } - }); - - return $query->whereYear('created_at', $year) - ->whereMonth('created_at', $month) - ->avg('total_amount') ?? 0; - }; - - // Reusable subquery for operator/sub-operator IDs - $operatorSubquery = function ($query) { - $query->select('id') - ->from('users') - ->whereIn('operator_level', [User::OPERATOR_LEVEL_OPERATOR, User::OPERATOR_LEVEL_SUB_OPERATOR]); - }; - - // ISP's MRC (all customers) - $lastMonth = now()->subMonth(); - $ispMRC = [ - 'current_mrc' => $calculateCurrentMRC(), - 'this_month_avg_mrc' => $calculateMonthlyAvgMRC(now()->year, now()->month), - 'last_month_avg_mrc' => $calculateMonthlyAvgMRC($lastMonth->year, $lastMonth->month), - ]; - - // Clients MRC (same as ISP's MRC for all clients) - $clientsMRC = $ispMRC; - - // Clients of Operator MRC (customers created by operators AND sub-operators - both levels 30 and 40) - $operatorClientsMRC = [ - 'current_mrc' => $calculateCurrentMRC($operatorSubquery), - 'this_month_avg_mrc' => $calculateMonthlyAvgMRC(now()->year, now()->month, $operatorSubquery), - 'last_month_avg_mrc' => $calculateMonthlyAvgMRC($lastMonth->year, $lastMonth->month, $operatorSubquery), - ]; - - // 3-Month MRC Comparison Data for graphs - $monthsToCompare = 3; - $mrcComparison = collect(); - for ($i = $monthsToCompare - 1; $i >= 0; $i--) { - $month = now()->subMonths($i); - - // Cache ISP MRC to avoid duplicate query - $monthlyIspMrc = $calculateMonthlyAvgMRC($month->year, $month->month); - - $mrcComparison->push([ - 'month' => $month->format('M Y'), - 'isp_mrc' => $monthlyIspMrc, - 'clients_mrc' => $monthlyIspMrc, // Same as ISP - 'operator_clients_mrc' => $calculateMonthlyAvgMRC($month->year, $month->month, $operatorSubquery), - ]); - } - - return view('panels.admin.dashboard', compact( - 'stats', - 'statusDistribution', - 'expiringCustomers', - 'lowPerformingPackages', - 'paymentStats', - 'operatorPerformance', - 'revenueTrend', - 'customerGrowth', - 'ispInfo', - 'operatorInfo', - 'operatorClients', - 'ispMRC', - 'clientsMRC', - 'operatorClientsMRC', - 'mrcComparison' - )); - } - - /** - * Display users listing. - * Admin can only see users within their own tenant. - */ - public function users(): View - { - $users = User::with('roles') - ->where('tenant_id', auth()->user()->tenant_id) - ->where('is_subscriber', false) // Exclude customers (subscribers) - ->latest() - ->paginate(20); - - return view('panels.admin.users.index', compact('users')); - } - - /** - * Show create user form. - */ - public function usersCreate(): View - { - return view('panels.admin.users.create'); - } - - /** - * Store a newly created user. - */ - public function usersStore(Request $request) - { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'email' => 'required|email|unique:users,email', - 'password' => 'required|string|min:8|confirmed', - 'role' => 'required|exists:roles,slug', - ]); - - // Get the role to check its level - $role = Role::where('slug', $validated['role'])->firstOrFail(); - - // Authorization check: Verify current user can create users with this role level - if (! auth()->user()->canCreateUserWithLevel($role->level)) { - abort(403, 'You are not authorized to create users with this role.'); - } - - // Create the user with proper tenant isolation - $user = User::create([ - 'name' => $validated['name'], - 'email' => $validated['email'], - 'password' => bcrypt($validated['password']), - 'is_active' => true, - 'tenant_id' => auth()->user()->tenant_id, // Enforce tenant isolation - 'operator_level' => $role->level, - 'created_by' => auth()->id(), - ]); - - // Assign role using the model method that handles tenant_id - $user->assignRole($validated['role']); - - return redirect()->route('panel.admin.users') - ->with('success', 'User created successfully.'); - } - - /** - * Show edit user form. - * Enforce tenant isolation - Admin can only edit users in their own tenant. - */ - public function usersEdit($id): View - { - $user = User::with('roles') - ->where('tenant_id', auth()->user()->tenant_id) - ->findOrFail($id); - - return view('panels.admin.users.edit', compact('user')); - } - - /** - * Update the specified user. - * Enforce tenant isolation - Admin can only update users in their own tenant. - */ - public function usersUpdate(Request $request, $id) - { - $user = User::where('tenant_id', auth()->user()->tenant_id)->findOrFail($id); - - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'email' => 'required|email|unique:users,email,' . $id, - 'password' => 'nullable|string|min:8|confirmed', - 'role' => 'required|exists:roles,slug', - 'is_active' => 'nullable|boolean', - ]); - - // Update user data - $updateData = [ - 'name' => $validated['name'], - 'email' => $validated['email'], - 'is_active' => $request->has('is_active') ? (bool) $request->input('is_active') : false, - ]; - - // Only update password if provided - if (! empty($validated['password'])) { - $updateData['password'] = bcrypt($validated['password']); - } - - $user->update($updateData); - - // Update role - detach all and assign new role with proper tenant_id - $user->roles()->detach(); - $user->assignRole($validated['role']); - - return redirect()->route('panel.admin.users') - ->with('success', 'User updated successfully.'); - } - - /** - * Remove the specified user. - * Enforce tenant isolation - Admin can only delete users in their own tenant. - */ - public function usersDestroy($id) - { - $user = User::where('tenant_id', auth()->user()->tenant_id)->findOrFail($id); - - // Prevent deleting own account - if ($user->id === auth()->id()) { - return redirect()->route('panel.admin.users') - ->with('error', 'You cannot delete your own account.'); - } - - $user->delete(); - - return redirect()->route('panel.admin.users') - ->with('success', 'User deleted successfully.'); - } - - /** - * DEPRECATED: NetworkUser model has been eliminated. - * Network credentials are now stored directly in the User model. - * Customers should be managed via customer routes instead. - * - * Display network users listing. - * Enforce tenant isolation - Admin can only see network users in their own tenant. - */ - /* - public function networkUsers(): View - { - $tenantId = auth()->user()->tenant_id; - - $networkUsers = NetworkUser::with(['user', 'package']) - ->where('tenant_id', $tenantId) - ->latest() - ->paginate(20); - - $stats = [ - 'active' => NetworkUser::where('tenant_id', $tenantId)->where('status', 'active')->count(), - 'suspended' => NetworkUser::where('tenant_id', $tenantId)->where('status', 'suspended')->count(), - 'inactive' => NetworkUser::where('tenant_id', $tenantId)->where('status', 'inactive')->count(), - 'total' => NetworkUser::where('tenant_id', $tenantId)->count(), - ]; - - return view('panels.admin.network-users.index', compact('networkUsers', 'stats')); - } - */ - - /** - * DEPRECATED: NetworkUser model has been eliminated. - * Show the form for creating a new network user. - * Enforce tenant isolation - Only show customers, packages, and routers from Admin's tenant. - */ - /* - public function networkUsersCreate(): View - { - $tenantId = auth()->user()->tenant_id; - - $customers = User::where('tenant_id', $tenantId) - ->whereHas('roles', function ($query) { - $query->where('slug', 'customer'); - })->get(); - $packages = Package::where('tenant_id', $tenantId)->where('status', 'active')->get(); - $routers = MikrotikRouter::where('tenant_id', $tenantId)->where('status', 'active')->get(); - - return view('panels.admin.network-users.create', compact('customers', 'packages', 'routers')); - } - */ - - /** - * DEPRECATED: NetworkUser model has been eliminated. - * Store a newly created network user. - */ - /* - public function networkUsersStore(Request $request) - { - $validated = $request->validate([ - 'user_id' => 'required|exists:users,id', - 'username' => 'required|string|max:255|unique:customers,username', - 'password' => 'required|string|min:6', - 'package_id' => 'required|exists:packages,id', - 'service_type' => 'required|in:pppoe,hotspot,static', - 'status' => 'required|in:active,suspended,inactive', - ]); - - // Don't store the password in the database - it should be managed via router API - $networkUserData = [ - 'user_id' => $validated['user_id'], - 'username' => $validated['username'], - 'package_id' => $validated['package_id'], - 'service_type' => $validated['service_type'], - 'status' => $validated['status'], - ]; - - $networkUser = NetworkUser::create($networkUserData); - - // Push the password to the actual router via MikrotikService - // SECURITY WARNING: MikrotikService currently uses HTTP for router communication. - // For production environments, configure HTTPS with certificate validation in the - // MikrotikService to protect credentials during transmission. See MikrotikService - // class documentation for security considerations. - if ($validated['service_type'] === 'pppoe') { - // Select router with explicit ordering for consistency - $router = MikrotikRouter::where('status', 'active') - ->orderBy('id') - ->first(); - - if ($router) { - try { - $mikrotikService = app(MikrotikService::class); - - // Resolve PPPoE profile for this package and router - $profileName = 'default'; - $profileMapping = PackageProfileMapping::where('package_id', $validated['package_id']) - ->where('router_id', $router->id) - ->first(); - - if ($profileMapping && ! empty($profileMapping->profile_name)) { - $profileName = $profileMapping->profile_name; - } - - $mikrotikService->createPppoeUser([ - 'router_id' => $router->id, - 'username' => $validated['username'], - 'password' => $validated['password'], - 'service' => 'pppoe', - 'profile' => $profileName, - ]); - } catch (\Exception $e) { - // Log the error but don't fail the user creation - Log::warning('Failed to sync network user to router', [ - 'username' => $validated['username'], - 'error' => $e->getMessage(), - ]); - } - } - } - - return redirect()->route('panel.admin.network-users') - ->with('success', 'Network user created successfully.'); - } - */ - - /** - * DEPRECATED: NetworkUser model has been eliminated. - * Display the specified network user. - */ - /* - public function networkUsersShow($id): View - { - $networkUser = NetworkUser::with(['user', 'package'])->findOrFail($id); - - return view('panels.admin.network-users.show', compact('networkUser')); - } - */ - - /** - * DEPRECATED: NetworkUser model has been eliminated. - * Show the form for editing the specified network user. - */ - /* - public function networkUsersEdit($id): View - { - $networkUser = NetworkUser::findOrFail($id); - $customers = User::whereHas('roles', function ($query) { - $query->where('slug', 'customer'); - })->get(); - $packages = Package::where('status', 'active')->get(); - $routers = MikrotikRouter::where('status', 'active')->get(); - - return view('panels.admin.network-users.edit', compact('networkUser', 'customers', 'packages', 'routers')); - } - */ - - /** - * DEPRECATED: NetworkUser model has been eliminated. - * Update the specified network user. - */ - /* - public function networkUsersUpdate(Request $request, $id) - { - $networkUser = NetworkUser::findOrFail($id); - - // Capture original username before update for router sync - $originalUsername = $networkUser->username; - - $validated = $request->validate([ - 'user_id' => 'required|exists:users,id', - 'username' => 'required|string|max:255|unique:customers,username,' . $id, - 'password' => 'nullable|string|min:6', - 'package_id' => 'required|exists:packages,id', - 'service_type' => 'required|in:pppoe,hotspot,static', - 'status' => 'required|in:active,suspended,inactive', - ]); - - // Update only the allowed fields (not password) - $networkUserData = [ - 'user_id' => $validated['user_id'], - 'username' => $validated['username'], - 'package_id' => $validated['package_id'], - 'service_type' => $validated['service_type'], - 'status' => $validated['status'], - ]; - - $networkUser->update($networkUserData); - - // If password is provided, update it on the router via MikrotikService - // SECURITY WARNING: MikrotikService currently uses HTTP for router communication. - // For production environments, configure HTTPS with certificate validation in the - // MikrotikService to protect credentials during transmission. See MikrotikService - // class documentation for security considerations. - if (! empty($validated['password']) && $validated['service_type'] === 'pppoe') { - try { - $mikrotikService = app(MikrotikService::class); - - // Use original username to locate the user on the router - $mikrotikService->updatePppoeUser($originalUsername, [ - 'password' => $validated['password'], - ]); - } catch (\Exception $e) { - // Log the error but don't fail the update - Log::warning('Failed to sync password update to router', [ - 'original_username' => $originalUsername, - 'new_username' => $validated['username'], - 'error' => $e->getMessage(), - ]); - } - } - - return redirect()->route('panel.admin.network-users') - ->with('success', 'Network user updated successfully.'); - } - */ - - /** - * DEPRECATED: NetworkUser model has been eliminated. - * Remove the specified network user. - */ - /* - public function networkUsersDestroy($id) - { - $networkUser = NetworkUser::findOrFail($id); - $networkUser->delete(); - - return redirect()->route('panel.admin.network-users') - ->with('success', 'Network user deleted successfully.'); - } - */ - - /** - * DEPRECATED: NetworkUser model has been eliminated. - * Show the form for importing network users from router. - */ - /* - public function networkUsersImport(): View - { - $routers = MikrotikRouter::where('status', 'active')->get(); - - return view('panels.admin.network-users.import', compact('routers')); - } - */ - - /** - * DEPRECATED: NetworkUser model has been eliminated. - * Process the import of network users from router. - */ - /* - public function networkUsersProcessImport(Request $request) - { - $validated = $request->validate([ - 'router_id' => 'required|exists:mikrotik_routers,id', - 'skip_existing' => 'boolean', - 'auto_create_customers' => 'boolean', - 'sync_packages' => 'boolean', - ]); - - try { - $mikrotikService = app(\App\Services\MikrotikService::class); - - // Import secrets (PPPoE users) from router - $secrets = $mikrotikService->importSecrets($validated['router_id']); - - if (empty($secrets)) { - return redirect()->route('panel.admin.network-users.import') - ->with('error', 'No users found on the selected router or unable to connect.'); - } - - $imported = 0; - $skipped = 0; - $errors = []; - - foreach ($secrets as $secret) { - try { - // Skip if user already exists - if ($validated['skip_existing'] ?? true) { - if (NetworkUser::where('username', $secret['name'])->exists()) { - $skipped++; - - continue; - } - } - - // Find or create customer if auto_create_customers is enabled - $userId = null; - if ($validated['auto_create_customers'] ?? false) { - $emailDomain = config('app.imported_user_domain', 'imported.local'); - $customer = User::firstOrCreate( - ['email' => $secret['name'] . '@' . $emailDomain], - [ - 'name' => $secret['name'], - 'password' => bcrypt(Str::random(32)), // Strong random password - ] - ); - $userId = $customer->id; - } - - // Find package by profile name if sync_packages is enabled - $packageId = null; - if ($validated['sync_packages'] ?? true) { - $package = Package::where('name', 'like', '%' . ($secret['profile'] ?? 'default') . '%')->first(); - $packageId = $package?->id; - } - - // Normalize disabled flag and determine status - $disabledRaw = $secret['disabled'] ?? false; - $isDisabled = in_array($disabledRaw, [true, 1, '1', 'yes', 'true', 'on'], true); - $status = $isDisabled ? 'inactive' : 'active'; - - // Create network user - don't store the password - NetworkUser::create([ - 'user_id' => $userId, - 'username' => $secret['name'], - 'service_type' => $secret['service'] ?? 'pppoe', - 'package_id' => $packageId, - 'status' => $status, - ]); - - // Note: Passwords remain on the router and are not stored in our database - // for security reasons. Users must be managed via the router API. - - $imported++; - } catch (\Exception $e) { - $errors[] = "Failed to import user {$secret['name']}: " . $e->getMessage(); - } - } - - $message = "Successfully imported {$imported} network users."; - if ($skipped > 0) { - $message .= " Skipped {$skipped} existing users."; - } - if (! empty($errors)) { - $message .= ' Encountered ' . count($errors) . ' errors.'; - } - - return redirect()->route('panel.admin.network-users') - ->with('success', $message) - ->with('import_errors', $errors); - - } catch (\Exception $e) { - Log::error('Network users import failed', [ - 'router_id' => $validated['router_id'], - 'error' => $e->getMessage(), - ]); - - return redirect()->route('panel.admin.network-users.import') - ->with('error', 'Import failed: ' . $e->getMessage()); - } - } - */ - - /** - * Display packages listing. - */ - public function packages(): View - { - $packages = ServicePackage::paginate(20); - - return view('panels.admin.packages.index', compact('packages')); - } - - /** - * Show the form for creating a new package. - */ - public function packagesCreate(): View - { - $profiles = MikrotikProfile::all(); - - return view('panels.admin.packages.create', compact('profiles')); - } - - /** - * Store a newly created package. - */ - public function packagesStore(Request $request) - { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'description' => 'nullable|string', - 'bandwidth_up' => 'nullable|integer|min:0', - 'bandwidth_down' => 'nullable|integer|min:0', - 'price' => 'required|numeric|min:1', - 'billing_cycle' => 'required|in:monthly,quarterly,half_yearly,yearly', - 'validity_days' => 'nullable|integer|min:1', - 'status' => 'required|in:active,inactive', - ], [ - 'price.min' => 'Package price must be at least $1.', - ]); - - // Tenant ID is automatically set by BelongsToTenant trait - ServicePackage::create($validated); - - return redirect()->route('panel.admin.packages.index') - ->with('success', 'Package created successfully.'); - } - - /** - * Show the form for editing the specified package. - */ - public function packagesEdit($id): View - { - // Find package within current tenant scope (automatically filtered by BelongsToTenant trait) - $package = ServicePackage::findOrFail($id); - - return view('panels.admin.packages.edit', compact('package')); - } - - /** - * Update the specified package. - */ - public function packagesUpdate(Request $request, $id) - { - // Find package within current tenant scope (automatically filtered by BelongsToTenant trait) - $package = ServicePackage::findOrFail($id); - - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'description' => 'nullable|string', - 'bandwidth_up' => 'nullable|integer|min:0', - 'bandwidth_down' => 'nullable|integer|min:0', - 'price' => 'required|numeric|min:1', - 'billing_cycle' => 'required|in:monthly,quarterly,half_yearly,yearly', - 'validity_days' => 'nullable|integer|min:1', - 'status' => 'required|in:active,inactive', - ], [ - 'price.min' => 'Package price must be at least $1.', - ]); - - $package->update($validated); - - return redirect()->route('panel.admin.packages.index') - ->with('success', 'Package updated successfully.'); - } - - /** - * Remove the specified package. - */ - public function packagesDestroy($id) - { - // Find package within current tenant scope (automatically filtered by BelongsToTenant trait) - $package = ServicePackage::findOrFail($id); - $package->delete(); - - return redirect()->route('panel.admin.packages.index') - ->with('success', 'Package deleted successfully.'); - } - - /** - * Display settings. - */ - public function settings(): View - { - return view('panels.admin.settings'); - } - - /** - * Display NAS devices listing. - * - * Displays paginated list of Network Access Server devices for admin users with full management access. - * Includes 20 items per page with tenant isolation automatically applied via BelongsToTenant trait. - */ - /** - * Display Cisco devices listing. - * - * Displays paginated list of Cisco network devices for admin users with full management access. - * Includes 20 items per page with tenant isolation automatically applied via BelongsToTenant trait. - */ - public function ciscoDevices(): View - { - $devices = CiscoDevice::latest()->paginate(20); - - return view('panels.admin.cisco.index', compact('devices')); - } - - /** - * Display customers listing with advanced filtering and caching. - */ - public function customers(Request $request): View - { - $tenantId = auth()->user()->tenant_id; - $roleId = auth()->user()->role_id; - $refresh = $request->boolean('refresh', false); - $perPage = $request->input('per_page', session('customers_per_page', 25)); - - // Save pagination preference - if ($request->has('per_page')) { - session(['customers_per_page' => $perPage]); - } - - // Initialize services - $cacheService = app(CustomerCacheService::class); - $filterService = app(CustomerFilterService::class); - - // Get cached customers - $allCustomers = $cacheService->getCustomers($tenantId, $roleId, $refresh); - - // Attach online status - $allCustomers = $cacheService->attachOnlineStatus($allCustomers, $refresh); - - // Apply filters - $filters = $request->only([ - 'connection_type', - 'billing_type', - 'status', - 'payment_status', - 'zone_id', - 'package_id', - 'device_type', - 'expiry_date_from', - 'expiry_date_to', - 'registration_date_from', - 'registration_date_to', - 'last_payment_date_from', - 'last_payment_date_to', - 'balance_min', - 'balance_max', - 'online_status', - 'search', - 'has_child_accounts', // Task 7.3: Filter for parent accounts (customers with children) - 'parent_id', // Task 7.3: Filter by specific parent to show child accounts - ]); - - $filteredCustomers = $filterService->applyFilters($allCustomers, $filters); - - // Manual pagination - $page = $request->input('page', 1); - $offset = ($page - 1) * $perPage; - $total = $filteredCustomers->count(); - $customers = new \Illuminate\Pagination\LengthAwarePaginator( - $filteredCustomers->slice($offset, $perPage)->values(), - $total, - $perPage, - $page, - ['path' => $request->url(), 'query' => $request->query()] - ); - - // Get filter options - $filterOptions = $filterService->getFilterOptions($tenantId); - $packages = ServicePackage::where('tenant_id', $tenantId)->get(); - $zones = \App\Models\Zone::where('tenant_id', $tenantId)->get(); - - $stats = [ - 'total' => $allCustomers->count(), - 'active' => $allCustomers->where('status', 'active')->count(), - 'online' => $allCustomers->where('online_status', true)->count(), - 'offline' => $allCustomers->where('online_status', false)->count(), - 'filtered' => $total, - ]; - - return view('panels.admin.customers.index', compact( - 'customers', - 'packages', - 'zones', - 'stats', - 'filters', - 'filterOptions', - 'perPage' - )); - } - - /** - * Show customer create form. - */ - public function customersCreate(): View - { - $packages = ServicePackage::all(); - - return view('panels.admin.customers.create', compact('packages')); - } - - /** - * Store a newly created customer. - * - * @return \Illuminate\Http\RedirectResponse - */ - public function customersStore(Request $request) - { - $validated = $request->validate([ - 'username' => 'required|string|min:3|max:255|unique:users,username|regex:/^[a-zA-Z0-9_-]+$/', - 'password' => 'required|string|min:8', - 'service_type' => 'required|in:pppoe,hotspot,cable-tv,static_ip,other', - 'package_id' => 'required|exists:packages,id', - 'status' => 'required|in:active,inactive,suspended', - 'customer_name' => 'nullable|string|max:255', - 'email' => 'nullable|email|max:255|unique:users,email', - 'phone' => 'nullable|string|max:20', - 'address' => 'nullable|string|max:500', - 'ip_address' => 'nullable|ip', - 'mac_address' => 'nullable|string|max:17|regex:/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/', - ]); - - try { - DB::beginTransaction(); - - // Create customer user with network credentials - $customer = User::create([ - 'tenant_id' => auth()->user()->tenant_id, - 'name' => $validated['customer_name'] ?? $validated['username'], - 'email' => $validated['email'] ?? $validated['username'] . '@local.customer', - 'username' => $validated['username'], - 'password' => bcrypt($validated['password']), // Hashed for app login - 'radius_password' => $validated['password'], // Plain text for RADIUS - 'phone' => $validated['phone'] ?? null, - 'address' => $validated['address'] ?? null, - 'operator_level' => User::OPERATOR_LEVEL_CUSTOMER, - 'is_active' => true, - 'is_subscriber' => true, // Mark as subscriber for customer list filtering - 'activated_at' => now(), - 'created_by' => auth()->id(), - 'service_package_id' => $validated['package_id'], - // Network service fields - 'service_type' => $validated['service_type'], - 'status' => $validated['status'], - 'ip_address' => $validated['ip_address'] ?? null, - 'mac_address' => $validated['mac_address'] ?? null, - ]); - - // Assign customer role - $customer->assignRole('customer'); - - // Note: RADIUS provisioning now happens automatically via UserObserver - // The observer will sync customer to RADIUS when created - - DB::commit(); - - // Clear customer cache to ensure new customer appears immediately - if (class_exists('\App\Services\CustomerCacheService')) { - \Cache::tags(['customers'])->flush(); - } - - return redirect()->route('panel.admin.customers.index') - ->with('success', 'Customer created successfully.'); - } catch (\Exception $e) { - DB::rollBack(); - Log::error('Failed to create customer: ' . $e->getMessage()); - - return redirect()->back() - ->withInput() - ->with('error', 'Failed to create customer: ' . $e->getMessage()); - } - } - - /** - * Show customer edit form. - */ - public function customersEdit($id): View - { - $tenantId = auth()->user()->tenant_id; - - // Try to find as User first (User model now has network fields) - $customer = User::where('tenant_id', $tenantId)->find($id); - - // If not found as User, try finding as NetworkUser and get the related User - if (! $customer) { - $networkUser = NetworkUser::with('user')->where('tenant_id', $tenantId)->find($id); - if ($networkUser && $networkUser->user) { - $customer = $networkUser->user; - } else { - abort(404, 'Customer not found'); - } - } - - $packages = ServicePackage::where('tenant_id', $tenantId)->get(); - - return view('panels.admin.customers.edit', compact('customer', 'packages')); - } - - /** - * Update the specified customer. - * - * @param int $id - * - * @return \Illuminate\Http\RedirectResponse - */ - public function customersUpdate(Request $request, $id) - { - $tenantId = auth()->user()->tenant_id; - - // Try to find as User first - $customer = User::where('tenant_id', $tenantId)->find($id); - - // If not found as User, try finding as NetworkUser and get the related User - if (! $customer) { - $networkUser = NetworkUser::with('user')->where('tenant_id', $tenantId)->find($id); - if ($networkUser && $networkUser->user) { - $customer = $networkUser->user; - } else { - abort(404, 'Customer not found'); - } - } - - $validated = $request->validate([ - 'username' => 'required|string|min:3|max:255|unique:users,username,' . $customer->id . '|regex:/^[a-zA-Z0-9_-]+$/', - 'password' => 'nullable|string|min:8', - 'service_type' => 'required|in:pppoe,hotspot,cable-tv,static-ip,other', - 'package_id' => 'required|exists:packages,id,tenant_id,' . $tenantId, - 'status' => 'required|in:active,inactive,suspended', - ]); - - // Prepare update data - $updateData = [ - 'username' => $validated['username'], - 'service_type' => $validated['service_type'], - 'service_package_id' => $validated['package_id'], // Map package_id from form to service_package_id field - 'status' => $validated['status'], - ]; - - // Only update password if provided (RADIUS password stored in plain text as required by RADIUS protocol) - if (! empty($validated['password'])) { - $updateData['radius_password'] = $validated['password']; - } - - $customer->update($updateData); - - return redirect()->route('panel.admin.customers.show', $customer->id) - ->with('success', 'Customer updated successfully.'); - } + $data = $request->validated(); - /** - * Partial update for inline editing (AJAX). - * Accepts User ID and partial field updates for different sections. - */ - public function customersPartialUpdate(Request $request, $id) - { - try { - // Load User model (not NetworkUser) since $id is User ID from the show page - $user = User::with('networkUser')->findOrFail($id); - - if (! $user->networkUser) { - return response()->json([ - 'success' => false, - 'message' => 'Network user not found for this customer.', - ], 404); - } - - $networkUser = $user->networkUser; - - // Authorize the action - $this->authorize('update', $user); - - // Handle different section updates based on provided fields - $updated = false; - - // General Information section - if ($request->has(['status', 'customer_name', 'phone', 'email', 'zone_id'])) { - $validated = $request->validate([ - 'status' => 'sometimes|in:active,inactive,suspended', - 'zone_id' => 'nullable|exists:zones,id', - ]); - - if (isset($validated['status'])) { - $networkUser->update(['status' => $validated['status']]); - $updated = true; - } - - if ($request->has('zone_id')) { - $networkUser->update(['zone_id' => $validated['zone_id']]); - $updated = true; - } - - // Update User model fields - $userValidated = $request->validate([ - 'customer_name' => 'sometimes|string|max:255', - 'phone' => 'sometimes|string|max:20', - 'email' => 'sometimes|email|max:255', - ]); - - if (! empty($userValidated)) { - $user->update([ - 'name' => $userValidated['customer_name'] ?? $user->name, - 'phone' => $userValidated['phone'] ?? $user->phone, - 'email' => $userValidated['email'] ?? $user->email, - ]); - $updated = true; - } - } - - // Credentials section - if ($request->has(['username', 'password'])) { - $validated = $request->validate([ - 'username' => 'sometimes|string|min:3|max:255|unique:customers,username,' . $networkUser->id . '|regex:/^[a-zA-Z0-9_-]+$/', - 'password' => 'nullable|string|min:8', - ]); - - $updateData = []; - if (isset($validated['username'])) { - $updateData['username'] = $validated['username']; - } - if (! empty($validated['password'])) { - $updateData['password'] = bcrypt($validated['password']); - } - - if (! empty($updateData)) { - $networkUser->update($updateData); - $updated = true; - } - } - - // Address section - if ($request->has(['address', 'city', 'zip_code', 'state'])) { - $validated = $request->validate([ - 'address' => 'nullable|string|max:500', - 'city' => 'nullable|string|max:100', - 'zip_code' => 'nullable|string|max:20', - 'state' => 'nullable|string|max:100', - ]); - - $user->update($validated); - $updated = true; - } - - // Network section - if ($request->has(['router_id', 'ip_address'])) { - $validated = $request->validate([ - 'router_id' => 'nullable|exists:mikrotik_routers,id', - 'ip_address' => 'nullable|ip', - ]); - - if (isset($validated['router_id'])) { - $networkUser->update(['router_id' => $validated['router_id']]); - $updated = true; - } - - // Handle IP address - update or create IpAllocation - if (isset($validated['ip_address'])) { - $ipAllocation = $user->ipAllocations()->first(); - if ($ipAllocation) { - $ipAllocation->update(['ip_address' => $validated['ip_address']]); - } else { - $user->ipAllocations()->create([ - 'ip_address' => $validated['ip_address'], - 'username' => $networkUser->username, - ]); - } - $updated = true; - } - } - - // MAC address section - if ($request->has('mac_address')) { - $validated = $request->validate([ - 'mac_address' => 'nullable|string|max:17', - ]); - - if (! empty($validated['mac_address'])) { - $macAddress = $user->macAddresses()->first(); - if ($macAddress) { - $macAddress->update(['mac_address' => $validated['mac_address']]); - } else { - $user->macAddresses()->create(['mac_address' => $validated['mac_address']]); - } - $updated = true; - } - } - - // Comments section - if ($request->has('comments')) { - $validated = $request->validate([ - 'comments' => 'nullable|string|max:1000', - ]); - - $networkUser->update(['comments' => $validated['comments']]); - $updated = true; - } - - if (! $updated) { - return response()->json([ - 'success' => false, - 'message' => 'No valid fields provided for update.', - ], 400); - } - - return response()->json([ - 'success' => true, - 'message' => 'Changes saved successfully.', - ]); - - } catch (\Illuminate\Validation\ValidationException $e) { - return response()->json([ - 'success' => false, - 'message' => 'Validation failed.', - 'errors' => $e->errors(), - ], 422); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { - return response()->json([ - 'success' => false, - 'message' => 'You are not authorized to update this customer.', - ], 403); - } catch (\Exception $e) { - \Log::error('Failed to update customer: ' . $e->getMessage()); - - return response()->json([ - 'success' => false, - 'message' => 'Failed to update customer. Please try again.', - ], 500); - } - } - - /** - * Remove the specified customer. - * - * @param int $id - * - * @return \Illuminate\Http\RedirectResponse - */ - public function customersDestroy($id) - { - $customer = NetworkUser::where('tenant_id', auth()->user()->tenant_id)->findOrFail($id); - $customer->delete(); - - return redirect()->route('panel.admin.customers.index') - ->with('success', 'Customer deleted successfully.'); + // Ensure sensible defaults + if (empty($data['port']) && in_array($data['management_protocol'], ['ssh', 'telnet'])) { + $data['port'] = $data['management_protocol'] === 'ssh' ? 22 : 23; } - - /** - * Show customer detail. - * Enforce tenant isolation - Admin can only view customers in their own tenant. - */ - public function customersShow($id): View - { - $tenantId = auth()->user()->tenant_id; - - // Load the User model which is what all customer actions expect - // The $id could be either User ID or NetworkUser ID, so we need to handle both cases - $customer = User::with([ - 'networkUser.package', - 'networkUser.sessions', - 'ipAllocations', - 'macAddresses', - ])->where('tenant_id', $tenantId)->find($id); - - // If not found as User, try finding as NetworkUser and get the related User - if (! $customer) { - // Mirror the eager loading done above by loading the same User relations via NetworkUser - $networkUser = NetworkUser::with([ - 'user.ipAllocations', - 'user.macAddresses', - 'package', - 'sessions', - ])->where('tenant_id', $tenantId)->find($id); - - if ($networkUser && $networkUser->user) { - $customer = $networkUser->user; - $customer->setRelation('networkUser', $networkUser); - } else { - if ($networkUser && ! $networkUser->user) { - \Log::warning('NetworkUser found without associated User', ['network_user_id' => $id]); - } - abort(404, 'Customer not found'); - } - } - - // Load ONU information if the customer has an associated NetworkUser - $onu = null; - if ($customer->networkUser) { - $onu = \App\Models\Onu::where('network_user_id', $customer->networkUser->id)->with('olt')->first(); - } - - // Load additional data for inline editing - enforcing tenant isolation - $packages = ServicePackage::where('tenant_id', $tenantId)->select('id', 'name')->orderBy('name')->get(); - $operators = \App\Models\User::where('tenant_id', $tenantId) - ->where(function ($q) { - $q->where('operator_level', 30)->orWhere('operator_level', 40); - }) - ->select('id', 'name', 'email') - ->orderBy('name') - ->get(); - $zones = \App\Models\Zone::where('tenant_id', $tenantId)->select('id', 'name')->orderBy('name')->get(); - $routers = \App\Models\MikrotikRouter::where('tenant_id', $tenantId)->select('id', 'name', 'ip_address')->orderBy('name')->get(); - - // Load recent activity data for tabs - $recentPayments = \App\Models\Payment::where('user_id', $customer->id) - ->where('tenant_id', $tenantId) - ->orderBy('created_at', 'desc') - ->limit(10) - ->get(); - - $recentInvoices = \App\Models\Invoice::where('user_id', $customer->id) - ->where('tenant_id', $tenantId) - ->orderBy('created_at', 'desc') - ->limit(10) - ->get(); - - $recentSmsLogs = \App\Models\SmsLog::where('user_id', $customer->id) - ->where('tenant_id', $tenantId) - ->orderBy('created_at', 'desc') - ->limit(10) - ->get(); - - $recentAuditLogs = \App\Models\AuditLog::where('auditable_type', 'App\Models\User') - ->where('auditable_id', $customer->id) - ->where('tenant_id', $tenantId) - ->with('user') - ->orderBy('created_at', 'desc') - ->limit(20) - ->get(); - - return view('panels.admin.customers.show', compact( - 'customer', - 'onu', - 'packages', - 'operators', - 'zones', - 'routers', - 'recentPayments', - 'recentInvoices', - 'recentSmsLogs', - 'recentAuditLogs' - )); + if (empty($data['snmp_port']) && $data['management_protocol'] === 'snmp') { + $data['snmp_port'] = 161; } - /** - * Suspend a customer. - */ - public function customersSuspend( - Request $request, - $id, - RadiusService $radiusService, - MikrotikService $mikrotikService, - NotificationService $notificationService, - AuditLogService $auditLogService - ) { - try { - // Use helper method to find and authorize customer - $customer = $this->findAndAuthorizeCustomer($id, 'suspend'); - - // Prevent suspending already suspended customers - if ($customer->status === 'suspended') { - return response()->json([ - 'success' => false, - 'message' => 'Customer is already suspended.', - ], 400); - } - - // Validate request data - $validatedData = $request->validate([ - 'reason' => ['sometimes', 'nullable', 'string', 'max:255'], - ]); - - // Get suspend reason from request - $reason = $validatedData['reason'] ?? 'Manual suspension by admin'; - - DB::beginTransaction(); - - try { - // Update customer status - $oldStatus = $customer->status; - $customer->status = 'suspended'; - $customer->save(); - - // RADIUS integration: Disable network access for PPPoE customers - if ($customer->service_type === 'pppoe' && $customer->username) { - try { - // Update RADIUS attributes to disable access - $radiusService->updateUser($customer->username, [ - 'Auth-Type' => 'Reject', - ]); - } catch (\Exception $e) { - Log::warning('Failed to update RADIUS for suspended customer', [ - 'customer_id' => $customer->id, - 'username' => $customer->username, - 'error' => $e->getMessage(), - ]); - } - } - - // MikroTik integration: Disconnect active sessions - if ($customer->username) { - try { - // Get first active router - $router = MikrotikRouter::where('is_active', true)->first(); - - if ($router && $mikrotikService->connectRouter($router->id)) { - // Get active sessions - $sessions = $mikrotikService->getActiveSessions($router->id); - - foreach ($sessions as $session) { - if (isset($session['name']) && $session['name'] === $customer->username) { - $mikrotikService->disconnectSession($session['id']); - } - } - } - } catch (\Exception $e) { - Log::warning('Failed to disconnect MikroTik sessions for suspended customer', [ - 'customer_id' => $customer->id, - 'username' => $customer->username, - 'error' => $e->getMessage(), - ]); - } - } + // Create and persist + $olt = Olt::create($data); - // Send notification to customer - if ($customer->user) { - try { - $notificationService->sendCustomerSuspendedNotification($customer->user, $reason); - } catch (\Exception $e) { - Log::warning('Failed to send suspension notification', [ - 'customer_id' => $customer->id, - 'error' => $e->getMessage(), - ]); - } - } - - // Audit logging - $auditLogService->log( - 'customer_suspended', - $customer, - ['status' => $oldStatus], - ['status' => 'suspended', 'reason' => $reason], - ['reason' => $reason] - ); - - DB::commit(); + return redirect()->route('panel.admin.network.olt') + ->with('success', 'OLT device created successfully.'); +} - // Clear cache - if (class_exists('\App\Services\CustomerCacheService')) { - \Cache::tags(['customers'])->flush(); - } +// Update existing OLT +public function oltUpdate(int $id, OltRequest $request) +{ + $olt = Olt::findOrFail($id); - return response()->json([ - 'success' => true, - 'message' => 'Customer suspended successfully.', - ]); - } catch (\Exception $e) { - DB::rollBack(); - throw $e; - } - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { - return response()->json([ - 'success' => false, - 'message' => 'You are not authorized to suspend this customer.', - ], 403); - } catch (\Exception $e) { - Log::error('Failed to suspend customer: ' . $e->getMessage(), [ - 'customer_id' => $id, - 'trace' => $e->getTraceAsString(), - ]); + $data = $request->validated(); - return response()->json([ - 'success' => false, - 'message' => 'Failed to suspend customer. Please try again.', - ], 500); - } + if (in_array($data['management_protocol'], ['ssh', 'telnet']) && empty($data['port'])) { + $data['port'] = $data['management_protocol'] === 'ssh' ? 22 : 23; } - /** - * Activate a customer. - */ - public function customersActivate( - $id, - RadiusService $radiusService, - MikrotikService $mikrotikService, - NotificationService $notificationService, - AuditLogService $auditLogService - ) { - try { - // Use helper method to find and authorize customer - $customer = $this->findAndAuthorizeCustomer($id, 'activate'); - - // Prevent activating already active customers - if ($customer->status === 'active') { - return response()->json([ - 'success' => false, - 'message' => 'Customer is already active.', - ], 400); - } - - DB::beginTransaction(); - - try { - // Update customer status - $oldStatus = $customer->status; - $customer->status = 'active'; - $customer->save(); - - // RADIUS integration: Enable network access for PPPoE customers - if ($customer->service_type === 'pppoe' && $customer->username) { - try { - // Sync user to RADIUS with proper attributes from package - $attributes = []; - - if ($customer->package) { - // Add speed limit attributes - if ($customer->package->bandwidth_upload && $customer->package->bandwidth_download) { - $attributes['Mikrotik-Rate-Limit'] = sprintf( - '%dk/%dk', - $customer->package->bandwidth_upload, - $customer->package->bandwidth_download - ); - } - } - - $radiusService->syncUser($customer, $attributes); - } catch (\Exception $e) { - Log::warning('Failed to sync RADIUS for activated customer', [ - 'customer_id' => $customer->id, - 'username' => $customer->username, - 'error' => $e->getMessage(), - ]); - } - } - - // MikroTik integration: Provision network access - if ($customer->service_type === 'pppoe' && $customer->username && $customer->password) { - try { - // Get first active router - $router = MikrotikRouter::where('is_active', true)->first(); - - if ($router && $mikrotikService->connectRouter($router->id)) { - // Check if user exists on router, update if exists, create if not - try { - $mikrotikService->updatePppoeUser([ - 'router_id' => $router->id, - 'username' => $customer->username, - 'password' => $customer->password, - 'profile' => $customer->package?->mikrotik_profile ?? 'default', - 'service' => 'pppoe', - ]); - } catch (\Exception $updateException) { - // If update fails, try create - $mikrotikService->createPppoeUser([ - 'router_id' => $router->id, - 'username' => $customer->username, - 'password' => $customer->password, - 'profile' => $customer->package?->mikrotik_profile ?? 'default', - 'service' => 'pppoe', - ]); - } - } - } catch (\Exception $e) { - Log::warning('Failed to provision MikroTik for activated customer', [ - 'customer_id' => $customer->id, - 'username' => $customer->username, - 'error' => $e->getMessage(), - ]); - } - } - - // Send notification to customer - if ($customer->user) { - try { - $notificationService->sendCustomerActivatedNotification($customer->user); - } catch (\Exception $e) { - Log::warning('Failed to send activation notification', [ - 'customer_id' => $customer->id, - 'error' => $e->getMessage(), - ]); - } - } - - // Audit logging - $auditLogService->log( - 'customer_activated', - $customer, - ['status' => $oldStatus], - ['status' => 'active'], - [] - ); - - DB::commit(); - - // Clear cache - if (class_exists('\App\Services\CustomerCacheService')) { - \Cache::tags(['customers'])->flush(); - } + $olt->update($data); - return response()->json([ - 'success' => true, - 'message' => 'Customer activated successfully.', - ]); - } catch (\Exception $e) { - DB::rollBack(); - throw $e; - } - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { - return response()->json([ - 'success' => false, - 'message' => 'You are not authorized to activate this customer.', - ], 403); - } catch (\Exception $e) { - Log::error('Failed to activate customer: ' . $e->getMessage(), [ - 'customer_id' => $id, - 'trace' => $e->getTraceAsString(), - ]); - - return response()->json([ - 'success' => false, - 'message' => 'Failed to activate customer. Please try again.', - ], 500); - } - } - - /** - * Display deleted customers. - */ - public function deletedCustomers(): View - { - $customers = User::onlyTrashed() - ->where('is_subscriber', true) - ->with(['zone']) - ->latest('deleted_at') - ->paginate(20); - - return view('panels.admin.customers.deleted', compact('customers')); - } - - /** - * Restore a soft-deleted customer. - */ - public function restoreCustomer(int $id): RedirectResponse - { - $customer = User::onlyTrashed() - ->where('is_subscriber', true) - ->findOrFail($id); - - $this->authorize('restore', $customer); - - $customer->restore(); - - return redirect()->route('panel.admin.customers.deleted') - ->with('success', 'Customer restored successfully.'); - } - - /** - * Permanently delete a customer. - */ - public function forceDeleteCustomer(int $id): RedirectResponse - { - $customer = User::onlyTrashed() - ->where('is_subscriber', true) - ->findOrFail($id); - - $this->authorize('forceDelete', $customer); - - $customer->forceDelete(); - - return redirect()->route('panel.admin.customers.deleted') - ->with('success', 'Customer permanently deleted.'); - } - - /** - * Display online customers. - */ - public function onlineCustomers(): View - { - $customers = NetworkUser::with('package')->where('status', 'active')->latest()->paginate(20); - - $stats = [ - 'online' => $customers->total(), - 'sessions' => 0, - ]; - - return view('panels.admin.customers.online', compact('customers', 'stats')); - } - - /** - * Display offline customers. - */ - public function offlineCustomers(): View - { - $customers = NetworkUser::with('package')->latest()->paginate(20); - - return view('panels.admin.customers.offline', compact('customers')); - } - - /** - * Display customer import requests. - */ - public function customerImportRequests(): View - { - // Customer import request tracking not yet implemented - // This feature requires creating an ImportRequest model and migration - // For now, return empty paginated collection to prevent blade errors - $importRequests = new \Illuminate\Pagination\LengthAwarePaginator( - [], - 0, - 20, - 1, - ['path' => request()->url(), 'query' => request()->query()] - ); - - return view('panels.admin.customers.import-requests', compact('importRequests')); - } - - /** - * Show PPPoE customer import form. - */ - public function pppoeCustomerImport(): View - { - $routers = MikrotikRouter::all(); - $packages = ServicePackage::all(); - - return view('panels.admin.customers.pppoe-import', compact('routers', 'packages')); - } - - /** - * Show bulk update form. - */ - public function bulkUpdateUsers(): View - { - $packages = ServicePackage::all(); - - return view('panels.admin.customers.bulk-update', compact('packages')); - } - - /** - * Display account transactions. - */ - public function accountTransactions(): View - { - return view('panels.admin.accounting.transactions'); - } - - /** - * Display payment gateway transactions. - */ - public function paymentGatewayTransactions(): View - { - return view('panels.admin.accounting.payment-gateway-transactions'); - } - - /** - * Display account statement. - */ - public function accountStatement(): View - { - return view('panels.admin.accounting.statement'); - } - - /** - * Display accounts payable. - */ - public function accountsPayable(): View - { - return view('panels.admin.accounting.payable'); - } - - /** - * Display accounts receivable. - */ - public function accountsReceivable(): View - { - return view('panels.admin.accounting.receivable'); - } - - /** - * Display income vs expense report. - */ - public function incomeExpenseReport(): View - { - return view('panels.admin.accounting.income-expense-report'); - } - - /** - * Display expense report. - */ - public function expenseReport(): View - { - return view('panels.admin.accounting.expense-report'); - } - - /** - * Display expenses management. - */ - public function expenses(): View - { - return view('panels.admin.accounting.expenses'); - } - - /** - * Display VAT collections. - */ - public function vatCollections(): View - { - return view('panels.admin.accounting.vat-collections'); - } - - /** - * Display customer payments. - */ - public function customerPayments(Request $request): View - { - $query = \App\Models\Payment::with(['user', 'invoice']) - ->latest(); - - // Search by customer name, username, or invoice number - if ($request->filled('search')) { - $search = $request->search; - $query->where(function ($q) use ($search) { - $q->whereHas('user', function ($userQuery) use ($search) { - $userQuery->where('name', 'like', "%{$search}%") - ->orWhere('username', 'like', "%{$search}%"); - })->orWhereHas('invoice', function ($invoiceQuery) use ($search) { - $invoiceQuery->where('invoice_number', 'like', "%{$search}%"); - }); - }); - } - - // Filter by payment method - if ($request->filled('method')) { - $query->where('payment_method', $request->method); - } - - // Filter by status - if ($request->filled('status')) { - $query->where('status', $request->status); - } - - // Filter by date range - if ($request->filled('date_from')) { - $query->whereDate('payment_date', '>=', $request->date_from); - } - if ($request->filled('date_to')) { - $query->whereDate('payment_date', '<=', $request->date_to); - } - - // Filter by amount range - if ($request->filled('amount_min')) { - $query->where('amount', '>=', $request->amount_min); - } - if ($request->filled('amount_max')) { - $query->where('amount', '<=', $request->amount_max); - } - - $payments = $query->paginate(50); - - // Calculate statistics - $stats = [ - 'total_collected' => \App\Models\Payment::where('status', 'completed')->sum('amount'), - 'this_month' => \App\Models\Payment::where('status', 'completed') - ->whereMonth('payment_date', now()->month) - ->whereYear('payment_date', now()->year) - ->sum('amount'), - 'total_payments' => \App\Models\Payment::count(), - 'pending_amount' => \App\Models\Payment::where('status', 'pending')->sum('amount'), - ]; - - return view('panels.admin.accounting.customer-payments', compact('payments', 'stats')); - } - - /** - * Display gateway customer payments. - */ - public function gatewayCustomerPayments(): View - { - return view('panels.admin.accounting.gateway-customer-payments'); - } - - /** - * Display operators listing. - */ - public function operators(): View - { - $user = auth()->user(); - $tenantId = $user->tenant_id; - - // Build base query with tenant scoping (unless Developer) - $baseQuery = User::with('roles') - ->whereHas('roles', function ($query) { - $query->whereIn('slug', ['manager', 'staff', 'operator', 'sub-operator']); - }); - - // Apply tenant filtering for non-Developer users - if (! $user->isDeveloper() && $tenantId) { - $baseQuery->where('tenant_id', $tenantId); - } - - $operators = $baseQuery->latest()->paginate(20); - - // Stats queries with same tenant scoping - $statsQuery = function () use ($user, $tenantId) { - $query = User::whereHas('roles', function ($query) { - $query->whereIn('slug', ['manager', 'staff', 'operator', 'sub-operator']); - }); - if (! $user->isDeveloper() && $tenantId) { - $query->where('tenant_id', $tenantId); - } - - return $query; - }; - - $stats = [ - 'total' => $statsQuery()->count(), - 'active' => $statsQuery()->where('is_active', true)->count(), - 'managers' => User::whereHas('roles', function ($query) { - $query->where('slug', 'manager'); - })->when(! $user->isDeveloper() && $tenantId, function ($q) use ($tenantId) { - $q->where('tenant_id', $tenantId); - })->count(), - 'staff' => User::whereHas('roles', function ($query) { - $query->where('slug', 'staff'); - })->when(! $user->isDeveloper() && $tenantId, function ($q) use ($tenantId) { - $q->where('tenant_id', $tenantId); - })->count(), - ]; - - return view('panels.admin.operators.index', compact('operators', 'stats')); - } - - /** - * Show create operator form. - */ - public function operatorsCreate(): View - { - return view('panels.admin.operators.create'); - } - - /** - * Show edit operator form. - */ - public function operatorsEdit($id): View - { - $operator = User::with('roles')->findOrFail($id); - - return view('panels.admin.operators.edit', compact('operator')); - } - - /** - * Store a newly created operator. - */ - public function operatorsStore(Request $request) - { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'email' => 'required|email|unique:users,email', - 'password' => 'required|string|min:8|confirmed', - 'company_name' => 'nullable|string|max:255', - 'company_address' => 'nullable|string', - 'company_phone' => 'nullable|string|max:20', - 'payment_type' => 'required|in:prepaid,postpaid', - 'credit_limit' => 'nullable|numeric|min:0', - 'sms_charges_by' => 'required|in:admin,operator', - 'sms_cost_per_unit' => 'nullable|numeric|min:0', - 'allow_sub_operator' => 'nullable|boolean', - 'allow_rename_package' => 'nullable|boolean', - 'can_manage_customers' => 'nullable|boolean', - 'can_view_financials' => 'nullable|boolean', - 'is_active' => 'nullable|boolean', - ]); - - // Create the user with all fields - $user = User::create([ - 'name' => $validated['name'], - 'email' => $validated['email'], - 'password' => bcrypt($validated['password']), - 'company_name' => $validated['company_name'] ?? null, - 'company_address' => $validated['company_address'] ?? null, - 'company_phone' => $validated['company_phone'] ?? null, - 'payment_type' => $validated['payment_type'], - 'credit_limit' => $validated['payment_type'] === 'postpaid' ? ($validated['credit_limit'] ?? 0) : 0, - 'sms_charges_by' => $validated['sms_charges_by'], - 'sms_cost_per_unit' => $validated['sms_charges_by'] === 'operator' ? ($validated['sms_cost_per_unit'] ?? 0) : 0, - 'allow_sub_operator' => $request->has('allow_sub_operator'), - 'allow_rename_package' => $request->has('allow_rename_package'), - 'can_manage_customers' => array_key_exists('can_manage_customers', $validated) ? (bool) $validated['can_manage_customers'] : true, - 'can_view_financials' => array_key_exists('can_view_financials', $validated) ? (bool) $validated['can_view_financials'] : true, - 'is_active' => $request->has('is_active'), - 'operator_level' => User::OPERATOR_LEVEL_OPERATOR, - 'operator_type' => 'operator', - 'tenant_id' => auth()->user()->tenant_id, - ]); - - // Assign operator role using the model method - $user->assignRole('operator'); - - return redirect()->route('panel.admin.operators') - ->with('success', 'Operator created successfully with all configurations.'); - } - - /** - * Update the specified operator. - */ - public function operatorsUpdate(Request $request, $id) - { - $operator = User::findOrFail($id); - - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'email' => 'required|email|unique:users,email,' . $id, - 'password' => 'nullable|string|min:8|confirmed', - 'operator_type' => 'nullable|string', - 'is_active' => 'nullable|boolean', - ]); - - // Update user data - $updateData = [ - 'name' => $validated['name'], - 'email' => $validated['email'], - 'operator_type' => $validated['operator_type'] ?? null, - 'is_active' => $request->has('is_active') ? (bool) $request->input('is_active') : false, - ]; - - // Only update password if provided - if (! empty($validated['password'])) { - $updateData['password'] = bcrypt($validated['password']); - } - - $operator->update($updateData); - - return redirect()->route('panel.admin.operators') - ->with('success', 'Operator updated successfully.'); - } - - /** - * Remove the specified operator. - */ - public function operatorsDestroy($id) - { - $operator = User::findOrFail($id); - - // Prevent deleting own account - if ($operator->id === auth()->id()) { - return redirect()->route('panel.admin.operators') - ->with('error', 'You cannot delete your own account.'); - } - - $operator->delete(); - - return redirect()->route('panel.admin.operators') - ->with('success', 'Operator deleted successfully.'); - } - - /** - * Display sub-operators hierarchy. - */ - public function subOperators(): View - { - $hierarchy = User::with(['roles', 'subordinates.roles']) - ->whereHas('roles', function ($query) { - $query->where('slug', 'manager'); - }) - ->get(); - - $stats = [ - 'supervisors' => User::whereHas('roles', function ($query) { - $query->where('slug', 'manager'); - })->count(), - 'subordinates' => User::whereHas('roles', function ($query) { - $query->where('slug', 'staff'); - })->count(), - 'avg_team_size' => 0, - ]; - - return view('panels.admin.operators.sub-operators', compact('hierarchy', 'stats')); - } - - /** - * Display staff members. - */ - public function staff(): View - { - $staff = User::with(['roles', 'supervisor']) - ->whereHas('roles', function ($query) { - $query->where('slug', 'staff'); - }) - ->latest() - ->paginate(20); - - $stats = [ - 'total' => User::whereHas('roles', function ($query) { - $query->where('slug', 'staff'); - })->count(), - 'active' => User::whereHas('roles', function ($query) { - $query->where('slug', 'staff'); - })->where('is_active', true)->count(), - 'on_duty' => 0, - 'departments' => 4, - ]; - - return view('panels.admin.operators.staff', compact('staff', 'stats')); - } - - /** - * Display operator profile. - */ - public function operatorProfile($id): View - { - $operator = User::with(['roles', 'supervisor'])->findOrFail($id); - - $stats = [ - 'customers_created' => 0, - 'tickets_resolved' => 0, - 'total_logins' => 0, - 'days_active' => $operator->created_at->diffInDays(now()), - ]; - - return view('panels.admin.operators.profile', compact('operator', 'stats')); - } - - /** - * Manage operator special permissions. - */ - public function operatorSpecialPermissions($id): View - { - $operator = User::with('roles')->findOrFail($id); - - return view('panels.admin.operators.special-permissions', compact('operator')); - } - - /** - * Update operator special permissions. - */ - public function updateOperatorSpecialPermissions(Request $request, $id) - { - $validated = $request->validate([ - 'permissions' => 'nullable|array', - 'permissions.*' => 'string', - ]); - - $operator = User::findOrFail($id); - - // Here you would update the operator's permissions - // This depends on your permission system implementation - // Example: $operator->syncPermissions($validated['permissions'] ?? []); - - return redirect() - ->route('panel.admin.operators.special-permissions', $id) - ->with('success', 'Special permissions updated successfully.'); - } - - /** - * Display payment gateways listing. - */ - public function paymentGateways(): View - { - $gateways = PaymentGateway::latest()->paginate(20); - - $totalPayments = Payment::whereNotNull('payment_gateway_id')->count(); - $completedPayments = Payment::whereNotNull('payment_gateway_id') - ->where('status', 'completed') - ->count(); - - $stats = [ - 'active' => PaymentGateway::where('is_active', true)->count(), - 'total_transactions' => $totalPayments, - 'success_rate' => $totalPayments > 0 ? round(($completedPayments / $totalPayments) * 100, 2) : 0, - 'total_amount' => Payment::whereNotNull('payment_gateway_id') - ->where('status', 'completed') - ->sum('amount'), - ]; - - return view('panels.admin.payment-gateways.index', compact('gateways', 'stats')); - } - - /** - * Show payment gateway create form. - */ - public function paymentGatewaysCreate(): View - { - return view('panels.admin.payment-gateways.create'); - } - - /** - * Store payment gateway. - */ - public function paymentGatewaysStore(Request $request) - { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'type' => 'required|string|in:bkash,nagad,rocket,ssl_commerz,aamarpay,stripe,paypal', - 'environment' => 'required|string|in:sandbox,production', - 'status' => 'required|string|in:active,inactive,testing,maintenance', - 'merchant_id' => 'required|string|max:255', - 'api_key' => 'required|string|max:255', - 'api_secret' => 'nullable|string|max:255', - 'webhook_url' => 'nullable|url|max:255', - ]); - - // Normalize type value to match PaymentGateway slug constants - $slug = $validated['type']; - if ($slug === 'ssl_commerz') { - $slug = PaymentGateway::TYPE_SSLCOMMERZ; - } - - // Build configuration array - $configuration = [ - 'merchant_id' => $validated['merchant_id'], - 'api_key' => $validated['api_key'], - 'api_secret' => $validated['api_secret'] ?? null, - 'webhook_url' => $validated['webhook_url'] ?? null, - 'environment' => $validated['environment'], - ]; - - PaymentGateway::create([ - 'tenant_id' => getCurrentTenantId(), - 'name' => $validated['name'], - 'slug' => $slug, - 'is_active' => $validated['status'] === 'active', - 'test_mode' => $validated['environment'] === 'sandbox', - 'configuration' => $configuration, - ]); - - return redirect()->route('panel.admin.payment-gateways') - ->with('success', 'Payment gateway configured successfully.'); - } - - /** - * Display network routers listing. - */ - public function routers(): View - { - $routers = MikrotikRouter::with('pppoeUsers', 'nas')->paginate(20); - - $stats = [ - 'total' => MikrotikRouter::count(), - 'online' => MikrotikRouter::where('status', 'online')->count(), - 'offline' => MikrotikRouter::where('status', 'offline')->count(), - 'warning' => 0, - ]; - - return view('panels.admin.network.routers', compact('routers', 'stats')); - } - - /** - * Show create router form. - */ - public function routersCreate(): View - { - return view('panels.admin.network.routers-create'); - } - - /** - * Store a newly created router. - */ - public function routersStore(Request $request) - { - $validated = $request->validate([ - 'router_name' => 'required|string|max:255', - 'ip_address' => 'required|ip|unique:mikrotik_routers,ip_address', - 'username' => 'required|string|max:100', - 'password' => 'required|string', - 'port' => 'nullable|integer|min:1|max:65535', - 'radius_secret' => 'required|string|max:255', - 'nas_shortname' => 'required|string|max:50|unique:nas,short_name', - 'nas_type' => 'nullable|string|in:mikrotik,cisco,juniper,other', - 'public_ip' => 'nullable|ip', - 'primary_auth' => 'nullable|in:radius,router,hybrid', - 'status' => 'required|in:active,inactive,maintenance', - ], [ - 'router_name.required' => 'Router name is required.', - 'ip_address.required' => 'IP address is required.', - 'ip_address.ip' => 'Please enter a valid IP address.', - 'ip_address.unique' => 'A router with this IP address already exists.', - 'username.required' => 'API username is required.', - 'password.required' => 'API password is required.', - 'port.integer' => 'Port must be a number.', - 'port.min' => 'Port must be at least 1.', - 'port.max' => 'Port must not exceed 65535.', - 'radius_secret.required' => 'RADIUS shared secret is required.', - 'nas_shortname.required' => 'NAS short name is required.', - 'nas_shortname.unique' => 'A NAS with this short name already exists.', - ]); - - // Default port to 8728 if not provided, allowing custom ports - $apiPort = $validated['port'] ?? 8728; - - // Validate port is within acceptable range (already done by validation rules, but double-check) - if ($apiPort < 1 || $apiPort > 65535) { - return redirect()->back() - ->withInput() - ->with('error', 'Invalid API port. Must be between 1 and 65535. Common ports: 8728 (non-SSL), 8729 (SSL).'); - } - - // Test router connectivity using RouterosAPI (IspBills pattern) - try { - $api = new \App\Services\RouterosAPI([ - 'host' => $validated['ip_address'], - 'user' => $validated['username'], - 'pass' => $validated['password'], - 'port' => $apiPort, - 'ssl' => $apiPort === 8729, // Auto-detect SSL for standard SSL port - 'attempts' => 1, - 'timeout' => (int) config('services.mikrotik.timeout', 30), - 'debug' => false, - ]); - - if (! $api->connect()) { - return redirect()->back() - ->withInput() - ->with('error', 'Cannot connect to the router! Please check: API port (' . $apiPort . '), username, password, and network connectivity. Ensure API service is enabled on the router.'); - } - - // Disconnect after successful test - $api->disconnect(); - } catch (\Exception $e) { - return redirect()->back() - ->withInput() - ->with('error', 'Router connection error: ' . $e->getMessage() . '. Please verify router settings and network connectivity.'); - } - - // Use database transaction to ensure both router and NAS are created together - DB::beginTransaction(); - try { - // Create or update NAS entry in RADIUS database - $nas = Nas::create([ - 'tenant_id' => auth()->user()->tenant_id, - 'name' => $validated['router_name'], - 'nas_name' => $validated['router_name'], - 'short_name' => $validated['nas_shortname'], - 'server' => $validated['ip_address'], - 'secret' => $validated['radius_secret'], - 'type' => $validated['nas_type'] ?? 'mikrotik', - 'ports' => 0, - 'status' => $validated['status'] === 'maintenance' ? 'inactive' : $validated['status'], - ]); - - // Map form fields to database columns - $router = MikrotikRouter::create([ - 'tenant_id' => auth()->user()->tenant_id, - 'nas_id' => $nas->id, - 'name' => $validated['router_name'], - 'ip_address' => $validated['ip_address'], - 'username' => $validated['username'], - 'password' => $validated['password'], - 'api_port' => $apiPort, - 'radius_secret' => $validated['radius_secret'], - 'public_ip' => $validated['public_ip'] ?? null, - 'primary_auth' => $validated['primary_auth'] ?? 'hybrid', - 'status' => $validated['status'] === 'maintenance' ? 'inactive' : $validated['status'], - ]); - - DB::commit(); - - return redirect()->route('panel.admin.network.routers') - ->with('success', 'Router and NAS entry created successfully!') - ->with('router_id', $router->id) - ->with('configure_prompt', 'Would you like to configure this router now for RADIUS integration and PPP profiles?'); - } catch (\Exception $e) { - DB::rollBack(); - Log::error('Failed to create router and NAS entry', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - return redirect()->back() - ->withInput() - ->with('error', 'Failed to create router. Please try again later.'); - } - } - - /** - * Show edit router form. - */ - public function routersEdit($id): View - { - $router = MikrotikRouter::with('nas')->findOrFail($id); - - return view('panels.admin.network.routers-edit', compact('router')); - } - - /** - * Update the specified router. - */ - public function routersUpdate(Request $request, $id) - { - $router = MikrotikRouter::findOrFail($id); - - $validated = $request->validate([ - 'router_name' => 'required|string|max:255', - 'ip_address' => 'required|ip|unique:mikrotik_routers,ip_address,' . $id, - 'username' => 'required|string|max:100', - 'password' => 'nullable|string', - 'port' => 'nullable|integer|min:1|max:65535', - 'radius_secret' => 'nullable|string|max:255', - 'nas_shortname' => 'required|string|max:50|unique:nas,short_name,' . ($router->nas_id ?? 'NULL'), - 'nas_type' => 'nullable|string|in:mikrotik,cisco,juniper,other', - 'public_ip' => 'nullable|ip', - 'primary_auth' => 'nullable|in:radius,router,hybrid', - 'status' => 'required|in:active,inactive,maintenance', - ]); - - DB::beginTransaction(); - try { - // Map form fields to database columns - $updateData = [ - 'name' => $validated['router_name'], - 'ip_address' => $validated['ip_address'], - 'username' => $validated['username'], - 'api_port' => $validated['port'] ?? $router->api_port, - 'public_ip' => $validated['public_ip'] ?? $router->public_ip, - 'primary_auth' => $validated['primary_auth'] ?? $router->primary_auth, - 'status' => $validated['status'] === 'maintenance' ? 'inactive' : $validated['status'], - ]; - - // Only update password if provided - if (! empty($validated['password'])) { - $updateData['password'] = $validated['password']; - } - - // Only update radius_secret if a non-empty value was provided - if (array_key_exists('radius_secret', $validated) - && $validated['radius_secret'] !== null - && $validated['radius_secret'] !== '') { - $updateData['radius_secret'] = $validated['radius_secret']; - } - - $router->update($updateData); - - // Update or create NAS entry - $nasData = [ - 'name' => $validated['router_name'], - 'nas_name' => $validated['router_name'], - 'short_name' => $validated['nas_shortname'], - 'server' => $validated['ip_address'], - 'type' => $validated['nas_type'] ?? 'mikrotik', - 'ports' => 0, - 'status' => $validated['status'] === 'maintenance' ? 'inactive' : $validated['status'], - ]; - - // Only update secret if provided - if (! empty($validated['radius_secret'])) { - $nasData['secret'] = $validated['radius_secret']; - } - - if ($router->nas_id) { - // Update existing NAS entry - Nas::where('id', $router->nas_id)->update($nasData); - } else { - // Create new NAS entry if it doesn't exist - // Require radius_secret when creating new NAS - if (empty($validated['radius_secret']) && empty($router->radius_secret)) { - throw new \Exception('RADIUS secret is required to create NAS entry'); - } - $nasData['tenant_id'] = auth()->user()->tenant_id; - $nasData['secret'] = $validated['radius_secret'] ?? $router->radius_secret; - $nas = Nas::create($nasData); - $router->update(['nas_id' => $nas->id]); - } - - DB::commit(); - - return redirect()->route('panel.admin.network.routers') - ->with('success', 'Router and linked NAS entry updated successfully.'); - } catch (\Exception $e) { - DB::rollBack(); - Log::error('Failed to update router and NAS entry', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - return redirect()->back() - ->withInput() - ->with('error', 'Failed to update router. Please try again later.'); - } - } - - /** - * Remove the specified router. - */ - public function routersDestroy($id) - { - $router = MikrotikRouter::findOrFail($id); - $router->delete(); - - return redirect()->route('panel.admin.network.routers') - ->with('success', 'Router deleted successfully.'); - } - - /** - * Display OLT devices listing. - */ - public function oltList(): View - { - $devices = Olt::latest()->paginate(20); - - $stats = [ - 'total' => Olt::count(), - 'active' => Olt::where('status', 'active')->count(), - 'total_onus' => 0, - 'online_onus' => 0, - ]; - - return view('panels.admin.network.olt', compact('devices', 'stats')); - } - - /** - * Show create OLT form. - */ - public function oltCreate(): View - { - return view('panels.admin.network.olt-create'); - } - - /** - * Display OLT dashboard. - */ - public function oltDashboard(): View - { - return view('panels.admin.olt.dashboard'); - } - - /** - * Display OLT monitor view for a specific OLT. - */ - public function oltMonitor(int $id): View - { - $olt = Olt::findOrFail($id); - - return view('panels.admin.olt.monitor', compact('olt')); - } - - /** - * Display OLT performance metrics view. - */ - public function oltPerformance(int $id): View - { - $olt = Olt::findOrFail($id); - - return view('panels.admin.olt.performance', compact('olt')); - } - - /** - * Display OLT configuration templates. - */ - public function oltTemplates(): View - { - return view('panels.admin.olt.templates'); - } - - /** - * Display OLT SNMP traps. - */ - public function oltSnmpTraps(): View - { - return view('panels.admin.olt.snmp-traps'); - } - - /** - * Display OLT firmware updates. - */ - public function oltFirmware(): View - { - return view('panels.admin.olt.firmware'); - } - - /** - * Display OLT backup management. - */ - public function oltBackups(): View - { - return view('panels.admin.olt.backups'); - } - - /** - * Display all network devices. - */ - public function devices(): View - { - // Combine all device types for unified view using a UNION query - $routerQuery = MikrotikRouter::select('id', 'name', 'ip_address as host', 'status', 'created_at') - ->addSelect(DB::raw("'router' as device_type")); - - $oltQuery = Olt::select('id', 'name', DB::raw('ip_address as host'), 'status', 'created_at') - ->addSelect(DB::raw("'olt' as device_type")); - - $ciscoQuery = CiscoDevice::select('id', 'name', DB::raw('ip_address as host'), 'status', 'created_at') - ->addSelect(DB::raw("'cisco' as device_type")); - - // Execute paginated query using UNION ALL - $devices = $routerQuery - ->unionAll($oltQuery) - ->unionAll($ciscoQuery) - ->orderByDesc('created_at') - ->paginate(20); - - $stats = [ - 'total' => MikrotikRouter::count() + Olt::count() + CiscoDevice::count(), - 'routers' => MikrotikRouter::count(), - 'olts' => Olt::count(), - 'switches' => CiscoDevice::count(), - 'online' => MikrotikRouter::where('status', 'active')->count() + - Olt::where('status', 'active')->count() + - CiscoDevice::where('status', 'active')->count(), - ]; - - return view('panels.admin.network.devices', compact('devices', 'stats')); - } - - /** - * Display device monitoring dashboard. - */ - public function deviceMonitors(): View - { - // Get actual device monitoring data using polymorphic relationships - $deviceMonitors = DeviceMonitor::with('monitorable') - ->latest() - ->limit(50) - ->get() - ->map(function ($monitor) { - $device = $monitor->monitorable; - - return (object) [ - 'id' => $monitor->id, - 'name' => $device?->name ?? 'Unknown Device', - 'ip_address' => $device?->ip_address ?? 'N/A', - 'type' => class_basename($monitor->monitorable_type), - 'status' => $monitor->status, - 'cpu_usage' => $monitor->cpu_usage, - 'memory_usage' => $monitor->memory_usage, - 'uptime' => $monitor->getUptimeHuman(), - 'ping' => $device?->response_time_ms ?? null, - 'load' => max($monitor->cpu_usage ?? 0, $monitor->memory_usage ?? 0), - 'last_check_at' => $monitor->last_check_at, - ]; - }); - - // Calculate health statistics based on device monitoring data - $onlineCount = DeviceMonitor::online()->count(); - $offlineCount = DeviceMonitor::offline()->count(); - $degradedCount = DeviceMonitor::degraded()->count(); - - // Total devices from all types - $totalDevices = MikrotikRouter::count() + Olt::count() + CiscoDevice::count(); - - $monitors = [ - 'healthy' => $onlineCount, - 'warning' => $degradedCount, - 'critical' => 0, // Placeholder for future threshold-based critical detection - 'offline' => $offlineCount, - 'devices' => $deviceMonitors, - 'alerts' => collect(), // Placeholder for future alert system implementation - ]; - - return view('panels.admin.network.device-monitors', compact('monitors')); - } - - /** - * Display devices map view. - */ - public function devicesMap(): View - { - $tenantId = getCurrentTenantId(); - - // Collect all network devices (routers, NAS, OLT) with location data - // Only include active/online devices (filter out inactive/deleted) - $routers = MikrotikRouter::where('tenant_id', $tenantId) - ->whereIn('status', ['active', 'online']) - ->select('id', 'name', 'ip_address', 'status') - ->get() - ->map(function ($router) { - return (object) [ - 'id' => $router->id, - 'name' => $router->name, - 'type' => 'router', - 'ip_address' => $router->ip_address, - 'location' => 'N/A', - 'latitude' => 0, - 'longitude' => 0, - 'status' => $router->status ?? 'unknown', - ]; - }); - - $nas = Nas::where('tenant_id', $tenantId) - ->whereIn('status', ['active', 'online']) - ->select('id', 'short_name', 'nas_name', 'description', 'status') - ->get() - ->map(function ($device) { - return (object) [ - 'id' => $device->id, - 'name' => $device->short_name, - 'type' => 'nas', - 'ip_address' => $device->nas_name, - 'location' => $device->description ?? 'N/A', - 'latitude' => 0, - 'longitude' => 0, - 'status' => $device->status ?? 'unknown', - ]; - }); - - // Add OLT devices - $olts = Olt::where('tenant_id', $tenantId) - ->whereIn('status', ['active', 'online']) - ->select('id', 'name', 'ip_address', 'location', 'status') - ->get() - ->map(function ($olt) { - return (object) [ - 'id' => $olt->id, - 'name' => $olt->name, - 'type' => 'olt', - 'ip_address' => $olt->ip_address, - 'location' => $olt->location ?? 'N/A', - 'latitude' => 0, - 'longitude' => 0, - 'status' => $olt->status ?? 'unknown', - ]; - }); - - $devices = $routers->concat($nas)->concat($olts); - - $stats = [ - 'online' => $devices->where('status', 'online')->count(), - 'offline' => $devices->where('status', 'offline')->count(), - 'warning' => $devices->where('status', 'warning')->count(), - 'critical' => $devices->where('status', 'critical')->count(), - ]; - - return view('panels.admin.network.devices-map', compact('devices', 'stats')); - } - - /** - * Display IPv4 pools management. - */ - public function ipv4Pools(): View - { - // CRITICAL: Filter by tenant_id to prevent data leakage - $tenantId = auth()->user()->tenant_id; - $pools = IpPool::where('tenant_id', $tenantId) - ->with('subnets') - ->latest() - ->paginate(20); - - $stats = [ - 'total' => IpPool::where('tenant_id', $tenantId)->count(), - 'available' => 0, // Placeholder for calculating available IPs based on pool capacity minus allocations - 'allocated' => IpAllocation::where('tenant_id', $tenantId)->count(), - 'pools' => $pools->total(), - ]; - - return view('panels.admin.network.ipv4-pools', compact('pools', 'stats')); - } - - /** - * Show create IPv4 pool form. - */ - public function ipv4PoolsCreate(): View - { - return view('panels.admin.network.ipv4-pools-create'); - } - - /** - * Store a newly created IPv4 pool. - */ - public function ipv4PoolsStore(Request $request) - { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'start_ip' => 'required|ip', - 'end_ip' => 'required|ip', - 'gateway' => 'nullable|ip', - 'dns_primary' => 'nullable|ip', - 'dns_secondary' => 'nullable|ip', - 'description' => 'nullable|string', - ]); - - // Map DNS fields to the schema's dns_servers column - $dnsServers = array_filter([ - $validated['dns_primary'] ?? null, - $validated['dns_secondary'] ?? null, - ]); - - // CRITICAL: Set tenant_id to ensure pool is properly scoped - IpPool::create([ - 'tenant_id' => auth()->user()->tenant_id, - 'name' => $validated['name'], - 'start_ip' => $validated['start_ip'], - 'end_ip' => $validated['end_ip'], - 'gateway' => $validated['gateway'] ?? null, - 'dns_servers' => ! empty($dnsServers) ? implode(',', $dnsServers) : null, - 'description' => $validated['description'] ?? null, - 'pool_type' => 'ipv4', - 'status' => 'active', - ]); - - return redirect()->route('panel.admin.network.ipv4-pools') - ->with('success', 'IPv4 pool created successfully.'); - } - - /** - * Show edit IPv4 pool form. - */ - public function ipv4PoolsEdit($id): View - { - // CRITICAL: Verify pool belongs to current tenant - $tenantId = auth()->user()->tenant_id; - $pool = IpPool::where('tenant_id', $tenantId)->findOrFail($id); - - return view('panels.admin.network.ipv4-pools-edit', compact('pool')); - } - - /** - * Update the specified IPv4 pool. - */ - public function ipv4PoolsUpdate(Request $request, $id) - { - // CRITICAL: Verify pool belongs to current tenant - $tenantId = auth()->user()->tenant_id; - $pool = IpPool::where('tenant_id', $tenantId)->findOrFail($id); - - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'start_ip' => 'required|ip', - 'end_ip' => 'required|ip', - 'gateway' => 'nullable|ip', - 'dns_primary' => 'nullable|ip', - 'dns_secondary' => 'nullable|ip', - 'description' => 'nullable|string', - ]); - - // Map DNS fields to the schema's dns_servers column - $dnsServers = array_filter([ - $validated['dns_primary'] ?? null, - $validated['dns_secondary'] ?? null, - ]); - - $pool->update([ - 'name' => $validated['name'], - 'start_ip' => $validated['start_ip'], - 'end_ip' => $validated['end_ip'], - 'gateway' => $validated['gateway'] ?? null, - 'dns_servers' => ! empty($dnsServers) ? implode(',', $dnsServers) : null, - 'description' => $validated['description'] ?? null, - ]); - - return redirect()->route('panel.admin.network.ipv4-pools') - ->with('success', 'IPv4 pool updated successfully.'); - } - - /** - * Remove the specified IPv4 pool. - */ - public function ipv4PoolsDestroy($id) - { - // CRITICAL: Verify pool belongs to current tenant - $tenantId = auth()->user()->tenant_id; - $pool = IpPool::where('tenant_id', $tenantId)->findOrFail($id); - $pool->delete(); - - return redirect()->route('panel.admin.network.ipv4-pools') - ->with('success', 'IPv4 pool deleted successfully.'); - } - - /** - * Bulk delete IPv4 pools. - */ - public function ipv4PoolsBulkDelete(Request $request) - { - $validated = $request->validate([ - 'ids' => 'required|array|min:1', - 'ids.*' => 'required|integer|exists:ip_pools,id', - ]); - - try { - // CRITICAL: Filter by tenant_id to prevent cross-tenant deletion - $tenantId = auth()->user()->tenant_id; - $deletedCount = IpPool::where('tenant_id', $tenantId) - ->whereIn('id', $validated['ids']) - ->delete(); - - return redirect()->route('panel.admin.network.ipv4-pools') - ->with('success', "{$deletedCount} IP pool(s) deleted successfully."); - } catch (\Exception $e) { - Log::error("Failed to bulk delete IP pools: " . $e->getMessage()); - - return redirect()->route('panel.admin.network.ipv4-pools') - ->with('error', 'Failed to delete IP pools. Please try again.'); - } - } - - /** - * Display IPv6 pools management. - */ - public function ipv6Pools(): View - { - // Filter IPv6 pools by checking for colon in start_ip (IPv6 format) - $pools = IpPool::where('start_ip', 'LIKE', '%:%')->latest()->paginate(20); - - $stats = [ - 'pools' => $pools->total(), - 'allocated' => IpAllocation::count(), - 'available' => 0, // Placeholder for calculating available IPs based on subnet capacity - ]; - - return view('panels.admin.network.ipv6-pools', compact('pools', 'stats')); - } - - /** - * Show create IPv6 pool form. - */ - public function ipv6PoolsCreate(): View - { - return view('panels.admin.network.ipv6-pools-create'); - } - - /** - * Store a newly created IPv6 pool. - */ - public function ipv6PoolsStore(Request $request) - { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'start_ip' => 'required|ipv6', - 'end_ip' => 'required|ipv6', - 'gateway' => 'nullable|ipv6', - 'dns_primary' => 'nullable|ipv6', - 'dns_secondary' => 'nullable|ipv6', - 'description' => 'nullable|string', - ]); - - // Map DNS fields to the schema's dns_servers column - $dnsServers = array_filter([ - $validated['dns_primary'] ?? null, - $validated['dns_secondary'] ?? null, - ]); - - IpPool::create([ - 'name' => $validated['name'], - 'start_ip' => $validated['start_ip'], - 'end_ip' => $validated['end_ip'], - 'gateway' => $validated['gateway'] ?? null, - 'dns_servers' => ! empty($dnsServers) ? implode(',', $dnsServers) : null, - 'description' => $validated['description'] ?? null, - 'pool_type' => 'ipv6', - 'status' => 'active', - ]); - - return redirect()->route('panel.admin.network.ipv6-pools') - ->with('success', 'IPv6 pool created successfully.'); - } - - /** - * Show edit IPv6 pool form. - */ - public function ipv6PoolsEdit($id): View - { - $pool = IpPool::findOrFail($id); - - return view('panels.admin.network.ipv6-pools-edit', compact('pool')); - } - - /** - * Update the specified IPv6 pool. - */ - public function ipv6PoolsUpdate(Request $request, $id) - { - $pool = IpPool::findOrFail($id); - - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'start_ip' => 'required|ipv6', - 'end_ip' => 'required|ipv6', - 'gateway' => 'nullable|ipv6', - 'dns_primary' => 'nullable|ipv6', - 'dns_secondary' => 'nullable|ipv6', - 'description' => 'nullable|string', - ]); - - // Map DNS fields to the schema's dns_servers column - $dnsServers = array_filter([ - $validated['dns_primary'] ?? null, - $validated['dns_secondary'] ?? null, - ]); - - $pool->update([ - 'name' => $validated['name'], - 'start_ip' => $validated['start_ip'], - 'end_ip' => $validated['end_ip'], - 'gateway' => $validated['gateway'] ?? null, - 'dns_servers' => ! empty($dnsServers) ? implode(',', $dnsServers) : null, - 'description' => $validated['description'] ?? null, - ]); - - return redirect()->route('panel.admin.network.ipv6-pools') - ->with('success', 'IPv6 pool updated successfully.'); - } - - /** - * Remove the specified IPv6 pool. - */ - public function ipv6PoolsDestroy($id) - { - $pool = IpPool::findOrFail($id); - $pool->delete(); - - return redirect()->route('panel.admin.network.ipv6-pools') - ->with('success', 'IPv6 pool deleted successfully.'); - } - - /** - * Display PPPoE profiles management. - */ - public function pppoeProfiles(): View - { - // CRITICAL: Filter by tenant_id to prevent data leakage - $tenantId = auth()->user()->tenant_id; - $profiles = MikrotikProfile::where('tenant_id', $tenantId) - ->with(['router', 'ipv4Pool', 'ipv6Pool']) - ->latest() - ->paginate(20); - - $stats = [ - 'total' => MikrotikProfile::where('tenant_id', $tenantId)->count(), - 'active' => MikrotikProfile::where('tenant_id', $tenantId)->count(), // Currently counts all profiles; adjust if a status field is introduced - 'users' => NetworkUser::where('tenant_id', $tenantId)->count(), - ]; - - $routers = MikrotikRouter::where('tenant_id', $tenantId)->where('status', 'active')->get(); - $ipv4Pools = IpPool::where('tenant_id', $tenantId)->where('pool_type', 'ipv4')->where('status', 'active')->get(); - $ipv6Pools = IpPool::where('tenant_id', $tenantId)->where('pool_type', 'ipv6')->where('status', 'active')->get(); - - return view('panels.admin.network.pppoe-profiles', compact('profiles', 'stats', 'routers', 'ipv4Pools', 'ipv6Pools')); - } - - /** - * Store a new PPPoE profile. - */ - public function pppoeProfilesStore(Request $request) - { - $validated = $request->validate([ - 'router_id' => 'required|exists:mikrotik_routers,id', - 'name' => [ - 'required', - 'string', - 'max:255', - // Ensure name is unique for the selected router - \Illuminate\Validation\Rule::unique('mikrotik_profiles')->where(function ($query) use ($request) { - return $query->where('router_id', $request->router_id); - }), - ], - 'ipv4_pool_id' => 'nullable|exists:ip_pools,id', - 'ipv6_pool_id' => 'nullable|exists:ip_pools,id', - 'local_address' => 'nullable|ip', - 'remote_address' => 'nullable|string|max:255', - 'rate_limit' => 'nullable|string|max:255', - 'session_timeout' => 'nullable|integer|min:0', - 'idle_timeout' => 'nullable|integer|min:0', - ]); - - MikrotikProfile::create($validated); - - return redirect()->route('panel.admin.network.pppoe-profiles') - ->with('success', 'PPPoE profile created successfully.'); - } - - /** - * Show edit form for a PPPoE profile. - */ - public function pppoeProfilesEdit($id) - { - $profile = MikrotikProfile::with(['router', 'ipv4Pool', 'ipv6Pool'])->findOrFail($id); - $routers = MikrotikRouter::where('status', 'active')->get(); - $ipv4Pools = IpPool::where('pool_type', 'ipv4')->where('status', 'active')->get(); - $ipv6Pools = IpPool::where('pool_type', 'ipv6')->where('status', 'active')->get(); - - return response()->json([ - 'profile' => $profile, - 'routers' => $routers, - 'ipv4Pools' => $ipv4Pools, - 'ipv6Pools' => $ipv6Pools, - ]); - } - - /** - * Update a PPPoE profile. - */ - public function pppoeProfilesUpdate(Request $request, $id) - { - $profile = MikrotikProfile::findOrFail($id); - - $validated = $request->validate([ - 'router_id' => 'required|exists:mikrotik_routers,id', - 'name' => [ - 'required', - 'string', - 'max:255', - // Ensure name is unique for the selected router, except for current profile - \Illuminate\Validation\Rule::unique('mikrotik_profiles')->where(function ($query) use ($request) { - return $query->where('router_id', $request->router_id); - })->ignore($id), - ], - 'ipv4_pool_id' => 'nullable|exists:ip_pools,id', - 'ipv6_pool_id' => 'nullable|exists:ip_pools,id', - 'local_address' => 'nullable|ip', - 'remote_address' => 'nullable|string|max:255', - 'rate_limit' => 'nullable|string|max:255', - 'session_timeout' => 'nullable|integer|min:0', - 'idle_timeout' => 'nullable|integer|min:0', - ]); - - $profile->update($validated); - - return redirect()->route('panel.admin.network.pppoe-profiles') - ->with('success', 'PPPoE profile updated successfully.'); - } - - /** - * Delete a PPPoE profile. - */ - public function pppoeProfilesDestroy($id) - { - // Ensure the profile belongs to a router in the current tenant - $profile = MikrotikProfile::where('id', $id) - ->whereHas('router') - ->firstOrFail(); - - $profile->delete(); - - return redirect()->route('panel.admin.network.pppoe-profiles') - ->with('success', 'PPPoE profile deleted successfully.'); - } - - /** - * Bulk delete PPPoE profiles. - */ - public function pppoeProfilesBulkDelete(Request $request) - { - $validated = $request->validate([ - 'ids' => 'required|array|min:1', - 'ids.*' => 'required|integer|exists:mikrotik_profiles,id', - ]); - - try { - // Filter profiles that belong to routers in the current tenant - $deletedCount = MikrotikProfile::whereIn('id', $validated['ids']) - ->whereHas('router') - ->delete(); - - return redirect()->route('panel.admin.network.pppoe-profiles') - ->with('success', "{$deletedCount} PPPoE profile(s) deleted successfully."); - } catch (\Exception $e) { - Log::error("Failed to bulk delete PPPoE profiles: " . $e->getMessage()); - - return redirect()->route('panel.admin.network.pppoe-profiles') - ->with('error', 'Failed to delete PPPoE profiles. Please try again.'); - } - } - - /** - * Show FUP editor for package. - */ - public function packageFupEdit($id): View - { - $package = ServicePackage::findOrFail($id); - - return view('panels.admin.network.package-fup-edit', compact('package')); - } - - /** - * Display ping test tool. - */ - public function pingTest(): View - { - return view('panels.admin.network.ping-test'); - } - - /** - * Display send SMS form. - */ - public function smsSend(): View - { - return view('panels.admin.sms.send'); - } - - /** - * Display SMS broadcast form. - */ - public function smsBroadcast(): View - { - return view('panels.admin.sms.broadcast'); - } - - /** - * Display SMS history. - */ - public function smsHistories(): View - { - return view('panels.admin.sms.histories'); - } - - /** - * Display SMS events configuration. - */ - public function smsEvents(): View - { - return view('panels.admin.sms.events'); - } - - /** - * Display due date notification configuration. - */ - public function dueDateNotification(): View - { - return view('panels.admin.sms.due-date-notification'); - } - - /** - * Display payment link broadcast form. - */ - public function paymentLinkBroadcast(): View - { - return view('panels.admin.sms.payment-link-broadcast'); - } - - /** - * Display router logs. - */ - public function routerLogs(): View - { - $tenantId = auth()->user()->tenant_id; - - // Get router connection logs from audit logs - $logs = \App\Models\AuditLog::where('tenant_id', $tenantId) - ->where(function ($query) { - $query->where('auditable_type', MikrotikRouter::class) - ->orWhere('event', 'like', '%router%'); - }) - ->with(['user', 'auditable']) - ->latest() - ->paginate(50); - - // Build base query for stats - $baseStatsQuery = \App\Models\AuditLog::where('tenant_id', $tenantId) - ->where('auditable_type', MikrotikRouter::class); - - $stats = [ - 'total' => (clone $baseStatsQuery)->count(), - 'today' => (clone $baseStatsQuery)->whereDate('created_at', today())->count(), - 'this_week' => (clone $baseStatsQuery)->whereBetween('created_at', [now()->copy()->startOfWeek(), now()->copy()->endOfWeek()])->count(), - 'this_month' => (clone $baseStatsQuery)->whereBetween('created_at', [now()->copy()->startOfMonth(), now()->copy()->endOfMonth()])->count(), - ]; - - return view('panels.admin.logs.router', compact('logs', 'stats')); - } - - /** - * Display RADIUS logs. - */ - public function radiusLogs(): View - { - $tenantId = auth()->user()->tenant_id; - - try { - // Get tenant usernames once to avoid subquery repetition - $tenantUsernames = \App\Models\User::where('tenant_id', $tenantId) - ->whereNotNull('username') - ->pluck('username') - ->toArray(); - - // If no users with usernames, return empty results - if (empty($tenantUsernames)) { - return view('panels.admin.logs.radius', [ - 'logs' => new \Illuminate\Pagination\LengthAwarePaginator([], 0, 50), - 'stats' => [ - 'total' => 0, - 'today' => 0, - 'active_sessions' => 0, - 'total_bandwidth' => 0, - ], - ]); - } - - // Get RADIUS accounting logs filtered by tenant users - $logs = \App\Models\RadAcct::whereIn('username', $tenantUsernames) - ->latest('acctstarttime') - ->paginate(50); - - // Build base query for stats - $baseStatsQuery = \App\Models\RadAcct::whereIn('username', $tenantUsernames); - - $stats = [ - 'total' => (clone $baseStatsQuery)->count(), - 'today' => (clone $baseStatsQuery)->whereDate('acctstarttime', today())->count(), - 'active_sessions' => (clone $baseStatsQuery)->whereNull('acctstoptime')->count(), - 'total_bandwidth' => (clone $baseStatsQuery) - ->selectRaw('SUM(acctinputoctets) + SUM(acctoutputoctets) as total') - ->value('total') ?? 0, - ]; - } catch (\Illuminate\Database\QueryException $e) { - // Handle missing RADIUS tables gracefully - session()->flash('error', 'RADIUS database table not found. Please run: php artisan radius:install --check for details.'); - - // Return empty data - $logs = new \Illuminate\Pagination\LengthAwarePaginator([], 0, 50); - $stats = [ - 'total' => 0, - 'today' => 0, - 'active_sessions' => 0, - 'total_bandwidth' => 0, - ]; - } - - return view('panels.admin.logs.radius', compact('logs', 'stats')); - } - - /** - * Display scheduler logs. - */ - public function schedulerLogs(): View - { - // Read scheduler log file if it exists - $logFile = storage_path('logs/scheduler.log'); - $logs = collect(); - - if (file_exists($logFile)) { - $content = file_get_contents($logFile); - $lines = explode("\n", $content); - - // Get last 100 scheduler entries - $recentLines = array_slice($lines, -100); - $parsedLogs = []; - - foreach ($recentLines as $line) { - if (empty(trim($line))) { - continue; - } - - if (preg_match('/\[(.*?)\]\s+(.*)/', $line, $matches)) { - $parsedLogs[] = [ - 'timestamp' => $matches[1] ?? now()->toDateTimeString(), - 'message' => $matches[2] ?? $line, - ]; - } - } - - $logs = collect(array_reverse($parsedLogs)); - } - - // Create paginator - $page = request()->get('page', 1); - $perPage = 20; - $logs = new \Illuminate\Pagination\LengthAwarePaginator( - $logs->forPage($page, $perPage), - $logs->count(), - $perPage, - $page, - ['path' => request()->url(), 'query' => request()->query()] - ); - - $stats = [ - 'total' => $logs->total(), - 'file_size' => file_exists($logFile) ? filesize($logFile) : 0, - ]; - - return view('panels.admin.logs.scheduler', compact('logs', 'stats')); - } - - /** - * Display activity logs. - */ - public function activityLogs(Request $request): View - { - $tenantId = auth()->user()->tenant_id; - - $query = \App\Models\AuditLog::where('tenant_id', $tenantId) - ->with(['user', 'auditable']); - - // Filter by customer_id if provided - if ($request->filled('customer_id')) { - // Verify user has permission to view this customer's logs - $customer = \App\Models\User::find($request->customer_id); - if ($customer && \Illuminate\Support\Facades\Gate::allows('view', $customer)) { - $query->where(function ($q) use ($request) { - $q->where('user_id', $request->customer_id) - ->orWhere('auditable_id', $request->customer_id); - }); - } - } - - $logs = $query->latest()->paginate(50); - - // Build base query for stats with tenant filtering - $baseStatsQuery = \App\Models\AuditLog::where('tenant_id', $tenantId); - - $stats = [ - 'total' => (clone $baseStatsQuery)->count(), - 'today' => (clone $baseStatsQuery)->whereDate('created_at', today())->count(), - 'this_week' => (clone $baseStatsQuery)->whereBetween('created_at', [now()->startOfWeek(), now()->endOfWeek()])->count(), - 'this_month' => (clone $baseStatsQuery)->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])->count(), - ]; - - return view('panels.admin.logs.activity', compact('logs', 'stats')); - } - - /** - * Display Laravel application logs. - */ - public function laravelLogs(): View - { - // Read Laravel log file - $logFile = storage_path('logs/laravel.log'); - $logs = collect(); - $stats = [ - 'info' => 0, - 'warning' => 0, - 'error' => 0, - 'debug' => 0, - 'total' => 0, - ]; - - if (file_exists($logFile)) { - $content = file_get_contents($logFile); - $lines = explode("\n", $content); - - // Parse log entries (last 200 lines for performance) - $recentLines = array_slice($lines, -200); - $parsedLogs = []; - - foreach ($recentLines as $line) { - if (empty(trim($line))) { - continue; - } - - if (preg_match('/\[(.*?)\]\s+\w+\.(INFO|WARNING|ERROR|DEBUG):\s+(.*)/', $line, $matches)) { - $level = strtolower($matches[2]); - $parsedLogs[] = [ - 'timestamp' => $matches[1] ?? now()->toDateTimeString(), - 'level' => $level, - 'message' => $matches[3] ?? $line, - ]; - $stats[$level] = ($stats[$level] ?? 0) + 1; - $stats['total']++; - } - } - - $logs = collect(array_reverse($parsedLogs)); - } - - // Create paginator - $page = request()->get('page', 1); - $perPage = 20; - $logs = new \Illuminate\Pagination\LengthAwarePaginator( - $logs->forPage($page, $perPage), - $logs->count(), - $perPage, - $page, - ['path' => request()->url(), 'query' => request()->query()] - ); - - return view('panels.admin.logs.laravel', compact('logs', 'stats')); - } - - /** - * Display PPP connection/disconnection logs. - */ - public function pppLogs(): View - { - $user = auth()->user(); - $userRole = $user->roles->first()?->slug ?? ''; - $tenantId = $user->tenant_id; - - try { - // Get tenant usernames to filter by tenant - $tenantUsernames = \App\Models\User::where('tenant_id', $tenantId) - ->whereNotNull('username') - ->pluck('username') - ->toArray(); - - // If no users with usernames, return empty results - if (empty($tenantUsernames)) { - return view('panels.admin.logs.ppp', [ - 'logs' => new \Illuminate\Pagination\LengthAwarePaginator([], 0, 50), - 'stats' => [ - 'total' => 0, - 'today' => 0, - 'active_sessions' => 0, - 'total_bandwidth' => 0, - ], - ]); - } - - // Base query for PPP sessions from RADIUS accounting with tenant filtering - $query = \App\Models\RadAcct::whereIn('username', $tenantUsernames) - ->where(function ($q) { - $q->where('username', 'LIKE', '%ppp%') - ->orWhere('nasporttype', 'PPP'); - }); - - // Additional filter by ownership for non-admin roles - if (! in_array($userRole, ['developer', 'super-admin', 'admin', 'manager'])) { - // For operators and staff, show only their assigned customers - if ($userRole === 'operator' || $userRole === 'staff') { - $customerIds = $user->customers()->pluck('id')->toArray(); - if (! empty($customerIds)) { - $customerUsernames = \App\Models\User::whereIn('id', $customerIds) - ->whereNotNull('username') - ->pluck('username') - ->toArray(); - if (! empty($customerUsernames)) { - $query->whereIn('username', $customerUsernames); - } else { - // No customers with usernames, return empty - $query->whereRaw('1 = 0'); - } - } else { - // No customers assigned, return empty - $query->whereRaw('1 = 0'); - } - } - // For customers, show only their own logs - elseif ($userRole === 'customer') { - $query->where('username', $user->username); - } - } - - $logs = $query->latest('acctstarttime')->paginate(50); - - $stats = [ - 'total' => (clone $query)->count(), - 'today' => (clone $query)->whereDate('acctstarttime', today())->count(), - 'active_sessions' => (clone $query)->whereNull('acctstoptime')->count(), - 'total_bandwidth' => (clone $query) - ->selectRaw('SUM(acctinputoctets) + SUM(acctoutputoctets) as total') - ->value('total') ?? 0, - ]; - } catch (\Illuminate\Database\QueryException $e) { - // Handle case where radacct table doesn't exist - $logs = new \Illuminate\Pagination\LengthAwarePaginator( - [], - 0, - 50, - 1, - ['path' => request()->url()] - ); - $stats = [ - 'total' => 0, - 'today' => 0, - 'active_sessions' => 0, - 'total_bandwidth' => 0, - ]; - - // Flash an informational message - session()->flash('error', 'RADIUS database table not found. Please ensure RADIUS is properly configured and migrations have been run.'); - } - - return view('panels.admin.logs.ppp', compact('logs', 'stats')); - } - - /** - * Display Hotspot connection/disconnection logs. - */ - public function hotspotLogs(): View - { - $user = auth()->user(); - $userRole = $user->roles->first()?->slug ?? ''; - $tenantId = $user->tenant_id; - - try { - // Get tenant usernames to filter by tenant - $tenantUsernames = \App\Models\User::where('tenant_id', $tenantId) - ->whereNotNull('username') - ->pluck('username') - ->toArray(); - - // If no users with usernames, return empty results - if (empty($tenantUsernames)) { - return view('panels.admin.logs.hotspot', [ - 'logs' => new \Illuminate\Pagination\LengthAwarePaginator([], 0, 50), - 'stats' => [ - 'total' => 0, - 'today' => 0, - 'active_sessions' => 0, - 'total_bandwidth' => 0, - ], - ]); - } - - // Base query for Hotspot sessions from RADIUS accounting with tenant filtering - $query = \App\Models\RadAcct::whereIn('username', $tenantUsernames) - ->where('username', 'NOT LIKE', '%ppp%') - ->where(function ($q) { - $q->where('nasporttype', 'Wireless-802.11') - ->orWhere('nasporttype', 'Ethernet') - ->orWhereNull('nasporttype'); - }); - - // Additional filter by ownership for non-admin roles - if (! in_array($userRole, ['developer', 'super-admin', 'admin', 'manager'])) { - // For operators and staff, show only their assigned customers - if ($userRole === 'operator' || $userRole === 'staff') { - $customerIds = $user->customers()->pluck('id')->toArray(); - if (! empty($customerIds)) { - $customerUsernames = \App\Models\User::whereIn('id', $customerIds) - ->whereNotNull('username') - ->pluck('username') - ->toArray(); - if (! empty($customerUsernames)) { - $query->whereIn('username', $customerUsernames); - } else { - // No customers with usernames, return empty - $query->whereRaw('1 = 0'); - } - } else { - // No customers assigned, return empty - $query->whereRaw('1 = 0'); - } - } - // For customers, show only their own logs - elseif ($userRole === 'customer') { - $query->where('username', $user->username); - } - } - - $logs = $query->latest('acctstarttime')->paginate(50); - - $stats = [ - 'total' => (clone $query)->count(), - 'today' => (clone $query)->whereDate('acctstarttime', today())->count(), - 'active_sessions' => (clone $query)->whereNull('acctstoptime')->count(), - 'total_bandwidth' => (clone $query) - ->selectRaw('SUM(acctinputoctets) + SUM(acctoutputoctets) as total') - ->value('total') ?? 0, - ]; - } catch (\Illuminate\Database\QueryException $e) { - // Handle case where radacct table doesn't exist - $logs = new \Illuminate\Pagination\LengthAwarePaginator( - [], - 0, - 50, - 1, - ['path' => request()->url()] - ); - $stats = [ - 'total' => 0, - 'today' => 0, - 'active_sessions' => 0, - 'total_bandwidth' => 0, - ]; - - // Flash an informational message - session()->flash('error', 'RADIUS database table not found. Please ensure RADIUS is properly configured and migrations have been run.'); - } - - return view('panels.admin.logs.hotspot', compact('logs', 'stats')); - } - - /** - * Download invoice as PDF. - */ - public function downloadInvoicePdf(Invoice $invoice, PdfService $pdfService): StreamedResponse - { - // Authorization check - ensure invoice belongs to user's tenant - $user = auth()->user(); - if ($invoice->tenant_id !== $user->tenant_id && ! $user->isDeveloper()) { - abort(403, 'Unauthorized access to invoice'); - } - - return $pdfService->downloadInvoicePdf($invoice); - } - - /** - * Stream invoice as PDF (display in browser). - */ - public function streamInvoicePdf(Invoice $invoice, PdfService $pdfService): StreamedResponse - { - // Authorization check - $user = auth()->user(); - if ($invoice->tenant_id !== $user->tenant_id && ! $user->isDeveloper()) { - abort(403, 'Unauthorized access to invoice'); - } - - return $pdfService->streamInvoicePdf($invoice); - } - - /** - * Download payment receipt as PDF. - */ - public function downloadPaymentReceiptPdf(Payment $payment, PdfService $pdfService): StreamedResponse - { - // Authorization check - $user = auth()->user(); - if ($payment->tenant_id !== $user->tenant_id && ! $user->isDeveloper()) { - abort(403, 'Unauthorized access to payment'); - } - - return $pdfService->downloadPaymentReceiptPdf($payment); - } - - /** - * Export invoices to Excel. - */ - public function exportInvoices(): \Symfony\Component\HttpFoundation\BinaryFileResponse - { - $user = auth()->user(); - - // Get invoices based on user's access level - $query = Invoice::with(['user', 'package', 'payments']); - - // Apply tenant filtering - if (! $user->isDeveloper()) { - $query->where('tenant_id', $user->tenant_id); - } - - $invoices = $query->get(); - - return Excel::download(new InvoicesExport($invoices), 'invoices-' . now()->format('Y-m-d') . '.xlsx'); - } - - /** - * Export payments to Excel. - */ - public function exportPayments(): \Symfony\Component\HttpFoundation\BinaryFileResponse - { - $user = auth()->user(); - - // Get payments based on user's access level - $query = Payment::with(['invoice', 'invoice.user']); - - // Apply tenant filtering - if (! $user->isDeveloper()) { - $query->where('tenant_id', $user->tenant_id); - } - - $payments = $query->get(); - - return Excel::download(new PaymentsExport($payments), 'payments-' . now()->format('Y-m-d') . '.xlsx'); - } - - /** - * Generate customer statement PDF. - */ - public function customerStatementPdf(User $customer, PdfService $pdfService): StreamedResponse - { - $user = auth()->user(); - - // Authorization check - if ($customer->tenant_id !== $user->tenant_id && ! $user->isDeveloper()) { - abort(403, 'Unauthorized access to customer data'); - } - - // Get date range from request or default to current month - $startDate = request()->get('start_date', now()->startOfMonth()->toDateString()); - $endDate = request()->get('end_date', now()->endOfMonth()->toDateString()); - - $pdf = $pdfService->generateCustomerStatementPdf( - $customer->id, - $startDate, - $endDate, - $user->tenant_id - ); - - return $pdf->download("statement-{$customer->username}-" . now()->format('Y-m-d') . '.pdf'); - } - - /** - * Generate monthly report PDF. - */ - public function monthlyReportPdf(PdfService $pdfService): StreamedResponse - { - $user = auth()->user(); - - // Get year and month from request or default to current - $year = request()->get('year', now()->year); - $month = request()->get('month', now()->month); - - $pdf = $pdfService->generateMonthlyReportPdf( - $user->tenant_id, - $year, - $month - ); - - return $pdf->download("monthly-report-{$year}-{$month}.pdf"); - } - - /** - * Export transactions report - */ - public function exportTransactions(Request $request, ExcelExportService $excelService, PdfExportService $pdfService) - { - // $this->authorize('reports.export'); - - $startDate = $request->input('start_date', now()->startOfMonth()->format('Y-m-d')); - $endDate = $request->input('end_date', now()->endOfMonth()->format('Y-m-d')); - $format = $request->input('format', 'excel'); - - // Get transactions data (mock data for now - replace with actual query) - $transactions = collect([ - (object) [ - 'date' => now()->format('Y-m-d'), - 'type' => 'income', - 'description' => 'Payment received', - 'reference' => 'INV-001', - 'amount' => 1000, - 'balance' => 1000, - 'status' => 'completed', - ], - ]); - - if ($format === 'pdf') { - $pdf = $pdfService->generateTransactionsReportPdf($transactions, $startDate, $endDate); - - return $pdf->download('transactions_report_' . now()->format('Y-m-d') . '.pdf'); - } - - return $excelService->exportTransactions($transactions, 'transactions_report'); - } - - /** - * Export VAT collections report - */ - public function exportVatCollections(Request $request, ExcelExportService $excelService, PdfExportService $pdfService) - { - // $this->authorize('reports.export'); - - $startDate = $request->input('start_date', now()->startOfMonth()->format('Y-m-d')); - $endDate = $request->input('end_date', now()->endOfMonth()->format('Y-m-d')); - $format = $request->input('format', 'excel'); - - // Get VAT collections data from invoices - $vatCollections = Invoice::whereBetween('created_at', [$startDate, $endDate]) - ->with('user') - ->get() - ->map(function ($invoice) { - return (object) [ - 'invoice_number' => $invoice->invoice_number, - 'customer_name' => $invoice->user->name ?? 'N/A', - 'date' => $invoice->created_at->format('Y-m-d'), - 'subtotal' => $invoice->amount ?? $invoice->total_amount / 1.15, - 'vat_rate' => 15, - 'vat_amount' => $invoice->vat_amount ?? ($invoice->total_amount * 0.15 / 1.15), - 'total_amount' => $invoice->total_amount, - 'status' => $invoice->status, - ]; - }); - - if ($format === 'pdf') { - $pdf = $pdfService->generateVatCollectionsReportPdf($vatCollections, $startDate, $endDate); - - return $pdf->download('vat_collections_' . now()->format('Y-m-d') . '.pdf'); - } - - return $excelService->exportVatCollections($vatCollections, 'vat_collections'); - } - - /** - * Export expense report - */ - public function exportExpenseReport(Request $request, ExcelExportService $excelService, PdfExportService $pdfService) - { - // $this->authorize('reports.export'); - - $startDate = $request->input('start_date', now()->startOfMonth()->format('Y-m-d')); - $endDate = $request->input('end_date', now()->endOfMonth()->format('Y-m-d')); - $format = $request->input('format', 'excel'); - - // Get expenses data (mock data for now - replace with actual query) - $expenses = collect([ - (object) [ - 'date' => now()->format('Y-m-d'), - 'category' => 'Operational', - 'description' => 'Office supplies', - 'vendor' => 'ABC Suppliers', - 'amount' => 500, - 'payment_method' => 'cash', - 'status' => 'paid', - 'notes' => 'Monthly supplies', - ], - ]); - - if ($format === 'pdf') { - $pdf = $pdfService->generateExpenseReportPdf($expenses, $startDate, $endDate); - - return $pdf->download('expense_report_' . now()->format('Y-m-d') . '.pdf'); - } - - return $excelService->exportExpenseReport($expenses, 'expense_report'); - } - - /** - * Export income & expense report - */ - public function exportIncomeExpenseReport(Request $request, ExcelExportService $excelService, PdfExportService $pdfService) - { - // $this->authorize('reports.export'); - - $startDate = $request->input('start_date', now()->startOfMonth()->format('Y-m-d')); - $endDate = $request->input('end_date', now()->endOfMonth()->format('Y-m-d')); - $format = $request->input('format', 'excel'); - - // Get income and expense data - $incomeData = Payment::whereBetween('created_at', [$startDate, $endDate]) - ->where('status', 'completed') - ->get() - ->map(function ($payment) { - return (object) [ - 'date' => $payment->paid_at?->format('Y-m-d') ?? $payment->created_at->format('Y-m-d'), - 'type' => 'income', - 'category' => 'Payment', - 'description' => 'Payment from ' . ($payment->user->name ?? 'Customer'), - 'amount' => $payment->amount, - 'running_balance' => 0, // Calculate running balance - ]; - }); - - // Mock expense data - replace with actual expense model query - $expenseData = collect([]); - - $data = $incomeData->merge($expenseData)->sortBy('date'); - - if ($format === 'pdf') { - $pdf = $pdfService->generateIncomeExpenseReportPdf($data, $startDate, $endDate); - - return $pdf->download('income_expense_report_' . now()->format('Y-m-d') . '.pdf'); - } - - return $excelService->exportIncomeExpenseReport($data, $startDate, $endDate, 'income_expense_report'); - } - - /** - * Export accounts receivable report - */ - public function exportReceivable(Request $request, ExcelExportService $excelService) - { - // $this->authorize('reports.export'); - - $format = $request->input('format', 'excel'); - - // Get receivables data from unpaid invoices - $receivables = Invoice::where('status', '!=', 'paid') - ->with('user') - ->get() - ->map(function ($invoice) { - $dueDate = $invoice->due_date ?? $invoice->created_at->addDays(30); - $daysOverdue = now()->diffInDays($dueDate, false); - - return (object) [ - 'customer_name' => $invoice->user->name ?? 'N/A', - 'invoice_number' => $invoice->invoice_number, - 'invoice_date' => $invoice->created_at->format('Y-m-d'), - 'due_date' => $dueDate->format('Y-m-d'), - 'total_amount' => $invoice->total_amount, - 'paid_amount' => $invoice->paid_amount ?? 0, - 'balance_due' => $invoice->total_amount - ($invoice->paid_amount ?? 0), - 'days_overdue' => $daysOverdue < 0 ? abs($daysOverdue) : 0, - 'status' => $invoice->status, - ]; - }); - - return $excelService->exportReceivable($receivables, 'accounts_receivable'); - } - - /** - * Export accounts payable report - */ - public function exportPayable(Request $request, ExcelExportService $excelService) - { - // $this->authorize('reports.export'); - - $format = $request->input('format', 'excel'); - - // Mock payables data - replace with actual query when payable model exists - $payables = collect([ - (object) [ - 'vendor_name' => 'Internet Provider', - 'bill_number' => 'BILL-001', - 'bill_date' => now()->subDays(15)->format('Y-m-d'), - 'due_date' => now()->addDays(15)->format('Y-m-d'), - 'total_amount' => 50000, - 'paid_amount' => 0, - 'balance_due' => 50000, - 'days_overdue' => 0, - 'status' => 'pending', - ], - ]); - - return $excelService->exportPayable($payables, 'accounts_payable'); - } - - /** - * Login as operator (impersonate operator). - */ - public function loginAsOperator(Request $request, int $operatorId) - { - $currentUser = auth()->user(); - - // Only allow super-admins and admins to impersonate - if (! $currentUser->hasAnyRole(['super-admin', 'admin'])) { - abort(403, 'Unauthorized to impersonate users.'); - } - - // Scope query to current tenant and ensure target is an operator - $operator = User::where('id', $operatorId) - ->where('tenant_id', $currentUser->tenant_id) - ->whereHas('roles', function ($query) { - $query->whereIn('slug', ['operator', 'sub-operator', 'manager', 'staff']); - }) - ->firstOrFail(); - - // Store original admin ID in session - session(['impersonate_by' => $currentUser->id]); - session(['impersonate_at' => now()]); - session(['impersonating' => true]); - session(['impersonated_user_name' => $operator->name]); - - // Log audit if AuditLog model exists - try { - \App\Models\AuditLog::create([ - 'user_id' => $currentUser->id, - 'tenant_id' => $currentUser->tenant_id, - 'event' => 'login_as_operator', - 'auditable_type' => User::class, - 'auditable_id' => $operatorId, - 'new_values' => [ - 'operator_id' => $operatorId, - 'operator_name' => $operator->name, - ], - 'ip_address' => $request->ip(), - 'user_agent' => $request->userAgent(), - ]); - } catch (\Exception $e) { - // Audit logging failed, but continue with impersonation - \Illuminate\Support\Facades\Log::warning('Failed to log impersonation audit: ' . $e->getMessage()); - } - - // Login as operator - auth()->loginUsingId($operatorId); - - // Determine redirect route based on impersonated user's role - $redirectRoute = null; - - if (method_exists($operator, 'hasRole')) { - // Keep admins / super-admins on the admin dashboard - if ($operator->hasAnyRole(['super-admin', 'admin'])) { - $redirectRoute = 'panel.admin.dashboard'; - // Send operators to their own dashboard if route exists - } elseif ($operator->hasRole('operator')) { - $redirectRoute = \Illuminate\Support\Facades\Route::has('panel.operator.dashboard') - ? 'panel.operator.dashboard' - : 'panel.admin.dashboard'; - } - } - - try { - if ($redirectRoute !== null) { - return redirect()->route($redirectRoute) - ->with('success', 'You are now logged in as ' . $operator->name) - ->with('impersonating', true); - } - - // Fallback to a generic panel path if no role-specific route is available - return redirect('/panel') - ->with('success', 'You are now logged in as ' . $operator->name) - ->with('impersonating', true); - } catch (\Exception $e) { - // If the named route or redirect fails, use the generic panel path - return redirect('/panel') - ->with('success', 'You are now logged in as ' . $operator->name) - ->with('impersonating', true); - } - } - - /** - * Stop impersonating and return to admin account. - */ - public function stopImpersonating() - { - $adminId = session('impersonate_by'); - - if (! $adminId) { - return redirect()->route('panel.admin.dashboard') - ->with('error', 'No active impersonation session.'); - } - - // Sanity check: ensure the original admin still exists and is allowed - $admin = User::find($adminId); - $currentUser = auth()->user(); - - if ( - ! $admin || - ! $admin->hasAnyRole(['super-admin', 'admin']) || - ($currentUser && property_exists($currentUser, 'tenant_id') && $admin->tenant_id !== $currentUser->tenant_id) - ) { - // Clear impersonation data and do not restore an invalid or unauthorized admin account - session()->forget(['impersonate_by', 'impersonate_at', 'impersonating', 'impersonated_user_name']); - - return redirect()->route('panel.admin.dashboard') - ->with('error', 'Unable to restore the original admin account.'); - } - - // Clear impersonation session data before switching back - session()->forget(['impersonate_by', 'impersonate_at', 'impersonating', 'impersonated_user_name']); - - auth()->loginUsingId($admin->id); - - return redirect()->route('panel.admin.dashboard') - ->with('success', 'You are now logged back in as admin.'); - } - - /** - * Display operator wallet management page. - */ - public function operatorWallets(): View - { - $operators = User::whereHas('roles', function ($query) { - $query->where('slug', 'operator'); - })->latest()->paginate(20); - - return view('panels.admin.operators.wallets', compact('operators')); - } - - /** - * Show form to add funds to operator wallet. - */ - public function addOperatorFunds(User $operator): View - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - return view('panels.admin.operators.add-funds', compact('operator')); - } - - /** - * Process adding funds to operator wallet. - */ - public function storeOperatorFunds(Request $request, User $operator) - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - $validated = $request->validate([ - 'amount' => 'required|numeric|min:0.01', - 'description' => 'nullable|string|max:500', - ]); - - DB::beginTransaction(); - try { - $balanceBefore = $operator->wallet_balance ?? 0; - $balanceAfter = $balanceBefore + $validated['amount']; - - // Update operator wallet balance - $operator->update(['wallet_balance' => $balanceAfter]); - - // Record transaction - OperatorWalletTransaction::create([ - 'operator_id' => $operator->id, - 'transaction_type' => 'credit', - 'amount' => $validated['amount'], - 'balance_before' => $balanceBefore, - 'balance_after' => $balanceAfter, - 'description' => $validated['description'] ?? 'Manual fund addition by admin', - 'created_by' => auth()->id(), - ]); - - DB::commit(); - - return redirect()->route('panel.admin.operators.wallets') - ->with('success', 'Funds added successfully.'); - } catch (\Exception $e) { - DB::rollBack(); - - return back()->with('error', 'Failed to add funds: ' . $e->getMessage()); - } - } - - /** - * Show form to deduct funds from operator wallet. - */ - public function deductOperatorFunds(User $operator): View - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - return view('panels.admin.operators.deduct-funds', compact('operator')); - } - - /** - * Process deducting funds from operator wallet. - */ - public function processDeductOperatorFunds(Request $request, User $operator) - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - $validated = $request->validate([ - 'amount' => 'required|numeric|min:0.01|max:' . ($operator->wallet_balance ?? 0), - 'description' => 'nullable|string|max:500', - ]); - - DB::beginTransaction(); - try { - $balanceBefore = $operator->wallet_balance ?? 0; - $balanceAfter = $balanceBefore - $validated['amount']; - - // Update operator wallet balance - $operator->update(['wallet_balance' => $balanceAfter]); - - // Record transaction - OperatorWalletTransaction::create([ - 'operator_id' => $operator->id, - 'transaction_type' => 'debit', - 'amount' => $validated['amount'], - 'balance_before' => $balanceBefore, - 'balance_after' => $balanceAfter, - 'description' => $validated['description'] ?? 'Manual fund deduction by admin', - 'created_by' => auth()->id(), - ]); - - DB::commit(); - - return redirect()->route('panel.admin.operators.wallets') - ->with('success', 'Funds deducted successfully.'); - } catch (\Exception $e) { - DB::rollBack(); - - return back()->with('error', 'Failed to deduct funds: ' . $e->getMessage()); - } - } - - /** - * Display operator wallet transaction history. - */ - public function operatorWalletHistory(User $operator): View - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - $transactions = OperatorWalletTransaction::where('operator_id', $operator->id) - ->with('creator') - ->latest() - ->paginate(50); - - return view('panels.admin.operators.wallet-history', compact('operator', 'transactions')); - } - - /** - * Show form to add NTTN cost to operator. - */ - public function addOperatorNttnCost(User $operator): View - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - return view('panels.admin.operators.add-nttn-cost', compact('operator')); - } - - /** - * Process adding NTTN cost to operator. - */ - public function storeOperatorNttnCost(Request $request, User $operator) - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - $validated = $request->validate([ - 'amount' => 'required|numeric|min:0.01', - 'cost_date' => 'required|date', - 'description' => 'nullable|string|max:500', - ]); - - DB::beginTransaction(); - try { - // Record NTTN cost - OperatorCost::create([ - 'operator_id' => $operator->id, - 'cost_type' => 'nttn', - 'amount' => $validated['amount'], - 'cost_date' => $validated['cost_date'], - 'description' => $validated['description'] ?? 'NTTN cost added by admin', - 'created_by' => auth()->id(), - ]); - - DB::commit(); - - return redirect()->route('panel.admin.operators.cost-history', $operator->id) - ->with('success', 'NTTN cost added successfully.'); - } catch (\Exception $e) { - DB::rollBack(); - - return back()->with('error', 'Failed to add NTTN cost: ' . $e->getMessage()); - } - } - - /** - * Show form to add Bandwidth cost to operator. - */ - public function addOperatorBandwidthCost(User $operator): View - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - return view('panels.admin.operators.add-bandwidth-cost', compact('operator')); - } - - /** - * Process adding Bandwidth cost to operator. - */ - public function storeOperatorBandwidthCost(Request $request, User $operator) - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - $validated = $request->validate([ - 'amount' => 'required|numeric|min:0.01', - 'cost_date' => 'required|date', - 'description' => 'nullable|string|max:500', - ]); - - DB::beginTransaction(); - try { - // Record Bandwidth cost - OperatorCost::create([ - 'operator_id' => $operator->id, - 'cost_type' => 'bandwidth', - 'amount' => $validated['amount'], - 'cost_date' => $validated['cost_date'], - 'description' => $validated['description'] ?? 'Bandwidth cost added by admin', - 'created_by' => auth()->id(), - ]); - - DB::commit(); - - return redirect()->route('panel.admin.operators.cost-history', $operator->id) - ->with('success', 'Bandwidth cost added successfully.'); - } catch (\Exception $e) { - DB::rollBack(); - - return back()->with('error', 'Failed to add Bandwidth cost: ' . $e->getMessage()); - } - } - - /** - * Display operator cost history. - */ - public function operatorCostHistory(User $operator): View - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - $costs = OperatorCost::where('operator_id', $operator->id) - ->with('creator') - ->latest('cost_date') - ->paginate(50); - - $totalNttnCost = OperatorCost::where('operator_id', $operator->id) - ->where('cost_type', 'nttn') - ->sum('amount'); - - $totalBandwidthCost = OperatorCost::where('operator_id', $operator->id) - ->where('cost_type', 'bandwidth') - ->sum('amount'); - - return view('panels.admin.operators.cost-history', compact('operator', 'costs', 'totalNttnCost', 'totalBandwidthCost')); - } - - /** - * Display operator package rates. - */ - public function operatorPackageRates(): View - { - $tenantId = getCurrentTenantId(); - abort_unless($tenantId, 500, 'Tenant context not initialized.'); - - $operators = User::where('tenant_id', $tenantId) - ->whereHas('roles', function ($query) { - $query->where('slug', 'operator'); - })->with('packageRates.package')->latest()->paginate(20); - - return view('panels.admin.operators.package-rates', compact('operators')); - } - - /** - * Show form to assign package rates to operator. - */ - public function assignOperatorPackageRate(User $operator): View - { - $tenantId = getCurrentTenantId(); - abort_unless($tenantId, 500, 'Tenant context not initialized.'); - - // Ensure operator belongs to current tenant - abort_unless($operator->tenant_id === $tenantId, 403, 'Operator not found in your organization.'); - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - $packages = Package::where('tenant_id', $tenantId) - ->where(function ($query) use ($operator) { - $query->where('is_global', true) - ->orWhere('operator_id', $operator->id); - })->get(); - $existingRates = OperatorPackageRate::where('tenant_id', $tenantId) - ->where('operator_id', $operator->id) - ->pluck('package_id') - ->toArray(); - - return view('panels.admin.operators.assign-package-rate', compact('operator', 'packages', 'existingRates')); - } - - /** - * Store operator package rate assignment. - */ - public function storeOperatorPackageRate(Request $request, User $operator) - { - $tenantId = getCurrentTenantId(); - abort_unless($tenantId, 500, 'Tenant context not initialized.'); - - // Ensure operator belongs to current tenant - abort_unless($operator->tenant_id === $tenantId, 403, 'Operator not found in your organization.'); - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - $validated = $request->validate([ - 'package_id' => 'required|exists:packages,id', - 'custom_price' => 'required|numeric|min:1', - 'commission_percentage' => 'nullable|numeric|min:0|max:100', - ], [ - 'custom_price.min' => 'Custom price must be at least $1.', - ]); - - // Verify package belongs to current tenant - abort if not found - Package::where('id', $validated['package_id']) - ->where('tenant_id', $tenantId) - ->firstOrFail(); - - $validated['operator_id'] = $operator->id; - $validated['tenant_id'] = $tenantId; - - // Ensure commission_percentage has a default value of 0 if not provided - if ($validated['commission_percentage'] === null) { - $validated['commission_percentage'] = 0; - } - - OperatorPackageRate::updateOrCreate( - [ - 'operator_id' => $operator->id, - 'package_id' => $validated['package_id'], - ], - $validated - ); - - return redirect()->route('panel.admin.operators.package-rates') - ->with('success', 'Package rate assigned successfully.'); - } - - /** - * Delete operator package rate. - */ - public function deleteOperatorPackageRate(User $operator, $package) - { - $tenantId = getCurrentTenantId(); - abort_unless($tenantId, 500, 'Tenant context not initialized.'); - - // Ensure operator belongs to current tenant - abort_unless($operator->tenant_id === $tenantId, 403, 'Operator not found in your organization.'); - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - OperatorPackageRate::where('operator_id', $operator->id) - ->where('package_id', $package) - ->where('tenant_id', $tenantId) - ->delete(); - - return redirect()->route('panel.admin.operators.package-rates') - ->with('success', 'Package rate removed successfully.'); - } - - /** - * Display operator SMS rates. - */ - public function operatorSmsRates(): View - { - $operators = User::whereHas('roles', function ($query) { - $query->where('slug', 'operator'); - })->with('smsRate')->latest()->paginate(20); - - return view('panels.admin.operators.sms-rates', compact('operators')); - } - - /** - * Show form to assign SMS rate to operator. - */ - public function assignOperatorSmsRate(User $operator): View - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - $smsRate = OperatorSmsRate::where('operator_id', $operator->id)->first(); - - return view('panels.admin.operators.assign-sms-rate', compact('operator', 'smsRate')); - } - - /** - * Store operator SMS rate assignment. - */ - public function storeOperatorSmsRate(Request $request, User $operator) - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - $validated = $request->validate([ - 'rate_per_sms' => 'required|numeric|min:0', - 'bulk_rate_threshold' => 'required_with:bulk_rate_per_sms|nullable|integer|min:1', - 'bulk_rate_per_sms' => 'required_with:bulk_rate_threshold|nullable|numeric|min:0', - ]); - - $validated['operator_id'] = $operator->id; - - OperatorSmsRate::updateOrCreate( - ['operator_id' => $operator->id], - $validated - ); - - return redirect()->route('panel.admin.operators.sms-rates') - ->with('success', 'SMS rate assigned successfully.'); - } - - /** - * Delete operator SMS rate. - */ - public function deleteOperatorSmsRate(User $operator) - { - abort_unless($operator->isOperatorRole(), 403, 'User is not an operator.'); - - OperatorSmsRate::where('operator_id', $operator->id)->delete(); - - return redirect()->route('panel.admin.operators.sms-rates') - ->with('success', 'SMS rate removed successfully.'); - } - - // ==================== NAS Device CRUD Methods ==================== - - /** - * Display NAS devices list. - */ - public function nasList(): View - { - $devices = Nas::where('tenant_id', getCurrentTenantId()) - ->orderBy('created_at', 'desc') - ->paginate(20); - - return view('panels.admin.nas.index', compact('devices')); - } - - /** - * Show create NAS form. - */ - public function nasCreate(): View - { - return view('panels.admin.nas.create'); - } - - /** - * Store new NAS device. - */ - public function nasStore(Request $request) - { - $validated = $request->validate([ - 'name' => 'required|string|max:100', - 'nas_name' => 'required|string|max:100', - 'short_name' => 'required|string|max:50', - 'server' => 'required|ip|max:100|unique:nas,server', - 'secret' => 'required|string|max:100', - 'type' => 'required|string|max:50', - 'ports' => 'nullable|integer|min:0', - 'community' => 'nullable|string|max:100', - 'description' => 'nullable|string', - 'status' => 'required|in:active,inactive,maintenance', - ]); - - $validated['tenant_id'] = getCurrentTenantId(); - - Nas::create($validated); - - return redirect()->route('panel.admin.network.nas') - ->with('success', 'NAS device created successfully.'); - } - - /** - * Show NAS device details. - */ - public function nasShow($id): View - { - $device = Nas::where('tenant_id', getCurrentTenantId())->findOrFail($id); - - return view('panels.admin.nas.show', compact('device')); - } - - /** - * Show edit NAS form. - */ - public function nasEdit($id): View - { - $device = Nas::where('tenant_id', getCurrentTenantId())->findOrFail($id); - - return view('panels.admin.nas.edit', compact('device')); - } - - /** - * Update NAS device. - */ - public function nasUpdate(Request $request, $id) - { - $device = Nas::where('tenant_id', getCurrentTenantId())->findOrFail($id); - - $validated = $request->validate([ - 'name' => 'required|string|max:100', - 'nas_name' => 'required|string|max:100', - 'short_name' => 'required|string|max:50', - 'server' => 'required|ip|max:100|unique:nas,server,' . $id, - 'secret' => 'nullable|string|max:100', - 'type' => 'required|string|max:50', - 'ports' => 'nullable|integer|min:0', - 'community' => 'nullable|string|max:100', - 'description' => 'nullable|string', - 'status' => 'required|in:active,inactive,maintenance', - ]); - - // Preserve existing secret if the field was left empty in the update form - if (array_key_exists('secret', $validated) && ($validated['secret'] === null || $validated['secret'] === '')) { - unset($validated['secret']); - } - - $device->update($validated); - - return redirect()->route('panel.admin.network.nas') - ->with('success', 'NAS device updated successfully.'); - } - - /** - * Delete NAS device. - */ - public function nasDestroy($id) - { - $device = Nas::where('tenant_id', getCurrentTenantId())->findOrFail($id); - $device->delete(); - - return redirect()->route('panel.admin.network.nas') - ->with('success', 'NAS device deleted successfully.'); - } - - /** - * Test NAS connection. - */ - public function nasTestConnection($id) - { - $device = Nas::where('tenant_id', getCurrentTenantId())->findOrFail($id); - - // Validate server is a valid IP address before executing command - if (! filter_var($device->server, FILTER_VALIDATE_IP)) { - return response()->json([ - 'success' => false, - 'message' => 'Invalid IP address format', - ], 400); - } - - // Simple ping test with sanitized IP - $output = []; - $returnCode = 0; - $sanitizedIp = escapeshellarg($device->server); - exec("ping -c 1 -W 2 {$sanitizedIp}", $output, $returnCode); - - if ($returnCode === 0) { - return response()->json([ - 'success' => true, - 'message' => 'Connection successful', - ]); - } - - return response()->json([ - 'success' => false, - 'message' => 'Connection failed - Device unreachable', - ], 500); - } - - // ==================== OLT Device CRUD Methods ==================== - - /** - * Store new OLT device. - */ - public function oltStore(Request $request) - { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'brand' => 'required|string|max:50', - 'ip_address' => 'required|ip|unique:olts,ip_address', - 'model' => 'nullable|string|max:100', - 'firmware_version' => 'nullable|string|max:100', - 'telnet_port' => 'nullable|integer|min:1|max:65535', - 'username' => 'required|string|max:100', - 'password' => 'required|string', - 'snmp_version' => 'required|in:v1,v2c,v3', - 'snmp_community' => 'required_if:snmp_version,v1,v2c|nullable|string|max:255', - 'snmp_port' => 'nullable|integer|min:1|max:65535', - 'location' => 'nullable|string', - 'coverage_area' => 'nullable|string', - 'total_ports' => 'nullable|integer|min:1', - 'max_onus' => 'nullable|integer|min:1', - 'description' => 'nullable|string', - 'status' => 'required|in:active,inactive,maintenance', - ]); - - $validated['tenant_id'] = getCurrentTenantId(); - - // Set port from telnet_port if provided, otherwise default to 23 - $validated['port'] = $validated['telnet_port'] ?? 23; - - // Set management protocol based on port or default to telnet - $validated['management_protocol'] = 'telnet'; - - Olt::create($validated); - - return redirect()->route('panel.admin.network.olt') - ->with('success', 'OLT device created successfully.'); - } - - /** - * Show OLT device details. - */ - public function oltShow($id): View - { - $olt = Olt::where('tenant_id', getCurrentTenantId())->findOrFail($id); - - return view('panels.admin.olt.show', compact('olt')); - } - - /** - * Show edit OLT form. - */ - public function oltEdit($id): View - { - $olt = Olt::where('tenant_id', getCurrentTenantId())->findOrFail($id); - - return view('panels.admin.olt.edit', compact('olt')); - } - - /** - * Update OLT device. - */ - public function oltUpdate(Request $request, $id) - { - $olt = Olt::where('tenant_id', getCurrentTenantId())->findOrFail($id); - - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'brand' => 'required|string|max:50', - 'ip_address' => 'required|ip|unique:olts,ip_address,' . $id, - 'model' => 'nullable|string|max:100', - 'firmware_version' => 'nullable|string|max:100', - 'telnet_port' => 'nullable|integer|min:1|max:65535', - 'username' => 'required|string|max:100', - 'password' => 'nullable|string', // Optional on update - only update if provided - 'snmp_version' => 'required|in:v1,v2c,v3', - 'snmp_community' => 'required_if:snmp_version,v1,v2c|nullable|string|max:255', - 'snmp_port' => 'nullable|integer|min:1|max:65535', - 'location' => 'nullable|string', - 'coverage_area' => 'nullable|string', - 'total_ports' => 'nullable|integer|min:1', - 'max_onus' => 'nullable|integer|min:1', - 'description' => 'nullable|string', - 'status' => 'required|in:active,inactive,maintenance', - ]); - - // Set port from telnet_port if provided, otherwise keep existing or default to 23 - if (isset($validated['telnet_port'])) { - $validated['port'] = $validated['telnet_port']; - } - - // Set management protocol - $validated['management_protocol'] = 'telnet'; - - // Remove password from validated data if not provided (don't overwrite with null) - if (empty($validated['password'])) { - unset($validated['password']); - } - - $olt->update($validated); - - return redirect()->route('panel.admin.network.olt') - ->with('success', 'OLT device updated successfully.'); - } - - /** - * Delete OLT device. - */ - public function oltDestroy($id) - { - $olt = Olt::where('tenant_id', getCurrentTenantId())->findOrFail($id); - $olt->delete(); - - return redirect()->route('panel.admin.network.olt') - ->with('success', 'OLT device deleted successfully.'); - } - - /** - * Test OLT connection. - */ - public function oltTestConnection($id) - { - $olt = Olt::where('tenant_id', getCurrentTenantId())->findOrFail($id); - - // Validate IP address format before executing command - if (! filter_var($olt->ip_address, FILTER_VALIDATE_IP)) { - return response()->json([ - 'success' => false, - 'message' => 'Invalid IP address format', - ], 400); - } - - // Simple ping test with sanitized IP - $output = []; - $returnCode = 0; - $sanitizedIp = escapeshellarg($olt->ip_address); - exec("ping -c 1 -W 2 {$sanitizedIp}", $output, $returnCode); - - if ($returnCode === 0) { - return response()->json([ - 'success' => true, - 'message' => 'Connection successful', - ]); - } - - return response()->json([ - 'success' => false, - 'message' => 'Connection failed - Device unreachable', - ], 500); - } - - /** - * Test router connection. - */ - public function routerTestConnection($id) - { - $router = MikrotikRouter::where('tenant_id', getCurrentTenantId())->findOrFail($id); - - // Validate IP address format before executing command - if (! filter_var($router->ip_address, FILTER_VALIDATE_IP)) { - return response()->json([ - 'success' => false, - 'message' => 'Invalid IP address format', - ], 400); - } - - // Simple ping test with sanitized IP - $output = []; - $returnCode = 0; - $sanitizedIp = escapeshellarg($router->ip_address); - exec("ping -c 1 -W 2 {$sanitizedIp}", $output, $returnCode); - - if ($returnCode === 0) { - return response()->json([ - 'success' => true, - 'message' => 'Connection successful', - ]); - } - - return response()->json([ - 'success' => false, - 'message' => 'Connection failed - Device unreachable', - ], 500); - } - - // ==================== Mikrotik Monitoring & Configuration Methods ==================== - - /** - * Display Mikrotik monitoring dashboard. - */ - public function mikrotikMonitoring(): View - { - $tenantId = getCurrentTenantId(); - $routers = MikrotikRouter::where('tenant_id', $tenantId) - ->orderBy('name') - ->paginate(20); - - $stats = [ - 'total' => MikrotikRouter::where('tenant_id', $tenantId)->count(), - 'online' => MikrotikRouter::where('tenant_id', $tenantId) - ->where('status', 'online') - ->count(), - 'offline' => MikrotikRouter::where('tenant_id', $tenantId) - ->where('status', 'offline') - ->count(), - ]; - - return view('panels.admin.mikrotik.monitoring', compact('routers', 'stats')); - } - - /** - * Display individual Mikrotik router monitor. - */ - public function mikrotikMonitor($id): View - { - $router = MikrotikRouter::where('tenant_id', getCurrentTenantId()) - ->findOrFail($id); - - return view('panels.admin.mikrotik.monitor', compact('router')); - } - - /** - * Show Mikrotik configuration form. - */ - public function mikrotikConfigureShow($id): View - { - $router = MikrotikRouter::where('tenant_id', getCurrentTenantId()) - ->findOrFail($id); - - return view('panels.admin.mikrotik.configure', compact('router')); - } - - /** - * Apply configuration to Mikrotik router. - */ - public function mikrotikConfigure(Request $request, $id) - { - $router = MikrotikRouter::where('tenant_id', getCurrentTenantId()) - ->findOrFail($id); - - $validated = $request->validate([ - 'config_type' => 'required|string|in:pppoe,ippool,firewall,queue', - 'settings' => 'required|array', - ]); - - try { - // Prepare configuration data for the router - $config = [ - $validated['config_type'] => $validated['settings'], - ]; - - \Log::info('Mikrotik configuration request', [ - 'router_id' => $router->id, - 'router_name' => $router->name, - 'config_type' => $validated['config_type'], - 'settings' => $validated['settings'], - ]); - - // Apply configuration using MikrotikService - $mikrotikService = app(MikrotikService::class); - $success = $mikrotikService->configureRouter($router->id, $config); - - if ($success) { - return response()->json([ - 'success' => true, - 'message' => 'Configuration applied successfully to ' . $router->name, - 'router' => $router->name, - 'config_type' => $validated['config_type'], - ], 200); - } - - return response()->json([ - 'success' => false, - 'message' => 'Failed to apply configuration to the router. Please check the router connection and try again.', - 'router' => $router->name, - ], 400); - } catch (\Exception $e) { - \Log::error('Mikrotik configuration error', [ - 'router_id' => $router->id, - 'error' => $e->getMessage(), - ]); - - return response()->json([ - 'success' => false, - 'message' => 'Failed to process configuration: ' . $e->getMessage(), - ], 500); - } - } - - // ==================== Prepaid Card Management Methods ==================== - - /** - * Display recharge cards list. - */ - public function cardsIndex(): View - { - // Validate filters - $validated = request()->validate([ - 'search' => 'nullable|string|max:255', - 'status' => 'nullable|in:active,used,expired,cancelled', - ]); - - $query = \App\Models\RechargeCard::where('tenant_id', getCurrentTenantId()) - ->with(['generatedBy', 'assignedTo', 'usedBy']); - - // Apply filters - if (! empty($validated['status'])) { - $query->where('status', $validated['status']); - } - - if (! empty($validated['search'])) { - $search = $validated['search']; - $query->where(function ($q) use ($search) { - $q->where('card_number', 'like', "%{$search}%") - ->orWhere('pin', 'like', "%{$search}%"); - }); - } - - $cards = $query->latest()->paginate(20); - - $stats = [ - 'total_cards' => \App\Models\RechargeCard::where('tenant_id', getCurrentTenantId())->count(), - 'active_cards' => \App\Models\RechargeCard::where('tenant_id', getCurrentTenantId())->where('status', 'active')->count(), - 'used_cards' => \App\Models\RechargeCard::where('tenant_id', getCurrentTenantId())->where('status', 'used')->count(), - 'total_value' => \App\Models\RechargeCard::where('tenant_id', getCurrentTenantId())->where('status', 'active')->sum('denomination'), - ]; - - return view('panels.admin.cards.index', compact('cards', 'stats')); - } - - /** - * Show card generation form. - */ - public function cardsCreate(): View - { - $operators = User::where('tenant_id', getCurrentTenantId()) - ->whereHas('roles', function ($query) { - $query->where('slug', 'operator'); - }) - ->get(); - - return view('panels.admin.cards.create', compact('operators')); - } - - /** - * Generate cards. - */ - public function cardsStore(Request $request) - { - $validated = $request->validate([ - 'quantity' => 'required|integer|min:1|max:1000', - 'denomination' => 'required|numeric|min:1', - 'expires_at' => 'nullable|date|after:today', - 'assign_to' => 'nullable|exists:users,id,tenant_id,' . getCurrentTenantId(), - ]); - - $cardService = new \App\Services\CardDistributionService; - - $expiresAt = $validated['expires_at'] ? \Carbon\Carbon::parse($validated['expires_at']) : null; - - try { - $cards = $cardService->generateCards( - $validated['quantity'], - $validated['denomination'], - auth()->user(), - $expiresAt - ); - - // Assign to operator if specified - if (isset($validated['assign_to'])) { - $cardIds = collect($cards)->pluck('id')->toArray(); - $distributor = User::where('tenant_id', getCurrentTenantId()) - ->findOrFail($validated['assign_to']); - $cardService->assignCardsToDistributor($cardIds, $distributor); - } - } catch (\Throwable $e) { - \Log::error('Failed to generate or assign recharge cards.', [ - 'error' => $e->getMessage(), - 'user_id' => optional(auth()->user())->id, - 'quantity' => $validated['quantity'] ?? null, - 'denomination' => $validated['denomination'] ?? null, - ]); - - return redirect() - ->back() - ->withInput() - ->with('error', 'An error occurred while generating the cards. Please try again.'); - } - - return redirect()->route('panel.admin.cards.index') - ->with('success', count($cards) . ' cards generated successfully.'); - } - - /** - * Export cards to PDF/Excel. - */ - public function cardsExport(Request $request) - { - $validated = $request->validate([ - 'format' => 'required|in:pdf,excel', - 'card_ids' => 'required|array', - 'card_ids.*' => 'exists:recharge_cards,id', - ]); - - $cards = \App\Models\RechargeCard::whereIn('id', $validated['card_ids']) - ->where('tenant_id', getCurrentTenantId()) - ->get(); - - if ($cards->isEmpty()) { - return redirect() - ->back() - ->with('error', 'No cards found to export. Please ensure the selected cards belong to your organization.'); - } - - if ($validated['format'] === 'pdf') { - $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('panels.admin.cards.export-pdf', compact('cards')); - - return $pdf->download('recharge-cards-' . now()->format('Y-m-d') . '.pdf'); - } else { - // Excel export - return Excel::download( - new \App\Exports\RechargeCardsExport($cards), - 'recharge-cards-' . now()->format('Y-m-d') . '.xlsx' - ); - } - } - - /** - * Show card details. - */ - public function cardsShow($id): View - { - $card = \App\Models\RechargeCard::where('tenant_id', getCurrentTenantId()) - ->with(['generatedBy', 'assignedTo', 'usedBy']) - ->findOrFail($id); - - return view('panels.admin.cards.show', compact('card')); - } - - /** - * Assign cards to distributor. - */ - public function cardsAssign(Request $request) - { - $validated = $request->validate([ - 'card_ids' => 'required|array', - 'card_ids.*' => 'exists:recharge_cards,id', - 'distributor_id' => 'required|exists:users,id,tenant_id,' . getCurrentTenantId(), - ]); - - $cardService = new \App\Services\CardDistributionService; - $distributor = User::where('tenant_id', getCurrentTenantId()) - ->findOrFail($validated['distributor_id']); - - $assigned = $cardService->assignCardsToDistributor($validated['card_ids'], $distributor); - - return redirect()->back() - ->with('success', $assigned . ' cards assigned to ' . $distributor->name); - } - - /** - * Show used cards mapping. - */ - public function cardsUsedMapping(): View - { - $usedCards = \App\Models\RechargeCard::where('tenant_id', getCurrentTenantId()) - ->where('status', 'used') - ->with(['usedBy', 'assignedTo']) - ->latest('used_at') - ->paginate(20); - - return view('panels.admin.cards.used-mapping', compact('usedCards')); - } - - /** - * Show IP Pool Analytics Dashboard - */ - public function ipAnalytics(): View - { - $analytics = $this->getIpPoolAnalytics(); - $poolStats = $this->getPoolStats(); - $recentAllocations = $this->getRecentAllocations(); - - return view('panels.admin.network.ip-pool-analytics', compact('analytics', 'poolStats', 'recentAllocations')); - } - - /** - * Export IP Analytics - */ - public function exportIpAnalytics(Request $request) - { - $format = $request->get('format', 'pdf'); - - // Gather analytics data - $analytics = $this->getIpPoolAnalytics(); - $poolStats = $this->getPoolStats(); - $recentAllocations = $this->getRecentAllocations(); - - $data = compact('analytics', 'poolStats', 'recentAllocations'); - - switch ($format) { - case 'pdf': - $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('exports.ip-analytics-pdf', $data); - - return $pdf->download('ip-pool-analytics-' . date('Y-m-d') . '.pdf'); - - case 'excel': - return Excel::download( - new \App\Exports\IpAnalyticsExport($data), - 'ip-pool-analytics-' . date('Y-m-d') . '.xlsx' - ); - - case 'csv': - return Excel::download( - new \App\Exports\IpAnalyticsExport($data), - 'ip-pool-analytics-' . date('Y-m-d') . '.csv', - \Maatwebsite\Excel\Excel::CSV - ); - - default: - abort(400, 'Invalid export format'); - } - } - - /** - * Get IP pool analytics data - */ - protected function getIpPoolAnalytics(): array - { - $pools = IpPool::all(); - - $totalIps = $pools->sum('total_ips'); - $allocatedIps = $pools->sum('used_ips'); - $availableIps = $totalIps - $allocatedIps; - - return [ - 'total_ips' => $totalIps, - 'allocated_ips' => $allocatedIps, - 'available_ips' => $availableIps, - 'allocation_percent' => $totalIps > 0 ? ($allocatedIps / $totalIps) * 100 : 0, - 'available_percent' => $totalIps > 0 ? ($availableIps / $totalIps) * 100 : 0, - 'total_pools' => $pools->count(), - 'by_type' => $this->getPoolsByType($pools), - 'top_utilized' => $this->getTopUtilizedPools($pools), - ]; - } - - /** - * Get pool statistics - */ - protected function getPoolStats(): array - { - $pools = IpPool::all(); - - return $pools->map(function ($pool) { - $totalIps = $pool->total_ips; - $usedIps = $pool->used_ips; - $availableIps = $totalIps - $usedIps; - - return [ - 'name' => $pool->name, - 'description' => $pool->description, - 'start_ip' => $pool->start_ip, - 'end_ip' => $pool->end_ip, - 'gateway' => $pool->gateway, - 'total_ips' => $totalIps, - 'allocated_ips' => $usedIps, - 'available_ips' => $availableIps, - 'utilization_percent' => $pool->utilizationPercent(), - 'pool_type' => $pool->pool_type ?? 'standard', - ]; - })->toArray(); - } - - /** - * Get pools by type - */ - protected function getPoolsByType($pools): array - { - $byType = []; - - foreach ($pools as $pool) { - $type = $pool->pool_type ?? 'standard'; - - if (! isset($byType[$type])) { - $byType[$type] = [ - 'total' => 0, - 'allocated' => 0, - ]; - } - - $byType[$type]['total'] += $pool->total_ips; - $byType[$type]['allocated'] += $pool->used_ips; - } - - return $byType; - } - - /** - * Get top utilized pools - */ - protected function getTopUtilizedPools($pools): array - { - return $pools->map(function ($pool) { - return [ - 'name' => $pool->name, - 'total' => $pool->total_ips, - 'allocated' => $pool->used_ips, - 'utilization' => $pool->utilizationPercent(), - ]; - }) - ->sortByDesc('utilization') - ->take(5) - ->values() - ->toArray(); - } - - /** - * Get recent IP allocations - */ - protected function getRecentAllocations(): array - { - $allocations = IpAllocation::with('subnet.pool') - ->latest() - ->take(20) - ->get(); - - return $allocations->map(function ($allocation) { - // Get pool name from subnet relationship - $poolName = 'N/A'; - if ($allocation->subnet && $allocation->subnet->pool) { - $poolName = $allocation->subnet->pool->name; - } - - return [ - 'ip_address' => $allocation->ip_address, - 'pool_name' => $poolName, - 'assigned_to' => $allocation->username ?? 'N/A', - 'allocated_at' => $allocation->allocated_at ?? $allocation->created_at, - ]; - })->toArray(); - } - - /** - * Helper method to find and authorize a customer by ID. - * Handles both User ID and NetworkUser ID cases. - * - * @param mixed $id The customer ID (either User ID or NetworkUser ID) - * @param string $ability The authorization ability to check (e.g., 'suspend', 'activate') - * - * @return NetworkUser The NetworkUser instance - * - * @throws \Illuminate\Http\Exceptions\HttpResponseException - */ - private function findAndAuthorizeCustomer($id, string $ability): NetworkUser - { - $tenantId = auth()->user()->tenant_id; - - // Try to find as NetworkUser first - $customer = NetworkUser::with(['user', 'package'])->where('tenant_id', $tenantId)->find($id); - - // If not found as NetworkUser, try finding as User and get the related NetworkUser - if (! $customer) { - $user = User::where('tenant_id', $tenantId)->find($id); - if ($user) { - $customer = NetworkUser::with(['user', 'package']) - ->where('user_id', $user->id) - ->where('tenant_id', $tenantId) - ->first(); - } - } - - if (! $customer) { - abort(response()->json([ - 'success' => false, - 'message' => 'Customer not found or no network user configured.', - ], 404)); - } - - // Authorization check on the related User model - if (! $customer->user) { - abort(response()->json([ - 'success' => false, - 'message' => 'Customer user account not found.', - ], 404)); - } - - $this->authorize($ability, $customer->user); - - return $customer; - } -} + return redirect()->route('panel.admin.network.olt.edit', $olt->id) + ->with('success', 'OLT updated successfully.'); +} \ No newline at end of file diff --git a/app/Http/Requests/OltRequest.php b/app/Http/Requests/OltRequest.php new file mode 100644 index 00000000..c4672c72 --- /dev/null +++ b/app/Http/Requests/OltRequest.php @@ -0,0 +1,65 @@ +user()?->can('manage', \App\Models\Olt::class) ?? true; + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:100'], + 'ip_address' => ['required', 'ip'], + 'brand' => ['nullable', 'string', 'max:50'], + 'model' => ['nullable', 'string', 'max:100'], + 'management_protocol' => ['required', 'in:ssh,telnet,snmp'], + 'port' => ['nullable', 'integer', 'between:1,65535'], + 'snmp_port' => ['nullable', 'integer', 'between:1,65535'], + 'snmp_community' => ['nullable', 'string', 'max:500'], + 'username' => ['nullable', 'string', 'max:100'], + 'password' => ['nullable', 'string', 'max:200'], + 'status' => ['required', 'in:active,inactive,maintenance'], + 'onu_type' => ['nullable', 'in:epon,gpon,xpon'], // support ONU type validation + 'total_ports' => ['nullable','integer','min:0'], + 'max_onus' => ['nullable','integer','min:0'], + ]; + } + + public function withValidator($validator) + { + $validator->after(function ($validator) { + $protocol = $this->input('management_protocol'); + + if (in_array($protocol, ['ssh', 'telnet']) && ! $this->filled('port')) { + $validator->errors()->add('port', 'Port is required for SSH/Telnet management protocol.'); + } + + if ($protocol === 'snmp' && ! $this->filled('snmp_port')) { + $validator->errors()->add('snmp_port', 'SNMP port is required for SNMP management protocol.'); + } + + // If SSH/Telnet selected ensure username/password present + if (in_array($protocol, ['ssh', 'telnet']) && (! $this->filled('username') || ! $this->filled('password'))) { + $validator->errors()->add('credentials', 'Username and password are required for SSH/Telnet devices.'); + } + }); + } + + public function messages(): array + { + return [ + 'ip_address.ip' => 'Please provide a valid IPv4 or IPv6 address.', + 'management_protocol.in' => 'Management protocol must be one of: ssh, telnet, snmp.', + 'onu_type.in' => 'ONU type must be one of: epon, gpon, xpon.', + ]; + } +} \ No newline at end of file diff --git a/app/Models/Olt.php b/app/Models/Olt.php index 2fd19c52..cbf0992c 100644 --- a/app/Models/Olt.php +++ b/app/Models/Olt.php @@ -9,47 +9,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -/** - * OLT (Optical Line Terminal) Model - * - * Represents an OLT device that manages multiple ONUs. - * - * @property int $id - * @property int|null $tenant_id - * @property string $name - * @property string $ip_address - * @property int $port - * @property string $management_protocol - * @property string $username - * @property string $password - * @property string|null $snmp_community - * @property string|null $snmp_version - * @property string|null $model - * @property string|null $location - * @property string $status - * @property \Illuminate\Support\Carbon|null $last_backup_at - * @property \Illuminate\Support\Carbon|null $last_health_check_at - * @property string $health_status - * @property \Illuminate\Support\Carbon|null $created_at - * @property \Illuminate\Support\Carbon|null $updated_at - */ class Olt extends Model { use HasFactory; - /** - * The attributes that are mass assignable. - * - * @var array - */ protected $fillable = [ 'tenant_id', 'name', 'brand', 'ip_address', - 'port', - 'telnet_port', - 'management_protocol', + 'port', // SSH/Telnet port + 'management_protocol', // ssh, telnet, snmp 'username', 'password', 'snmp_community', @@ -67,24 +37,15 @@ class Olt extends Model 'last_health_check_at', ]; - /** - * The attributes that should be hidden for serialization. - * - * @var array - */ protected $hidden = [ 'password', 'username', 'snmp_community', ]; - /** - * The attributes that should be cast. - * - * @var array - */ protected $casts = [ 'port' => 'integer', + 'snmp_port' => 'integer', 'username' => 'encrypted', 'password' => 'encrypted', 'snmp_community' => 'encrypted', @@ -94,67 +55,38 @@ class Olt extends Model 'updated_at' => 'datetime', ]; - /** - * Get the tenant that owns the OLT. - */ public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } - /** - * Get the ONUs for the OLT. - */ public function onus(): HasMany { return $this->hasMany(Onu::class); } - /** - * Get the backups for the OLT. - */ public function backups(): HasMany { return $this->hasMany(OltBackup::class); } - /** - * Scope a query to only include active OLTs. - */ public function scopeActive($query) { return $query->where('status', 'active'); } - /** - * Scope a query to only include inactive OLTs. - */ - public function scopeInactive($query) - { - return $query->where('status', 'inactive'); - } - - /** - * Scope a query to only include OLTs in maintenance mode. - */ - public function scopeMaintenance($query) - { - return $query->where('status', 'maintenance'); - } - - /** - * Check if the OLT is active. - */ public function isActive(): bool { return $this->status === 'active'; } - /** - * Check if the OLT can be connected to. - */ public function canConnect(): bool { + // Allow SNMP-only devices (no username/password), or SSH/Telnet requiring credentials + if ($this->management_protocol === 'snmp') { + return $this->isActive() && ! empty($this->ip_address); + } + return $this->isActive() && ! empty($this->ip_address) && ! empty($this->username) && ! empty($this->password); } -} +} \ No newline at end of file diff --git a/app/Services/OltService.php b/app/Services/OltService.php index cc61086d..2acc3517 100644 --- a/app/Services/OltService.php +++ b/app/Services/OltService.php @@ -1,102 +1,79 @@ - [ + 'onu_status' => '1.3.6.1.4.1.3320.101.11.4.1.5', + 'onu_mac_sn' => '1.3.6.1.4.1.3320.101.11.1.1.2', + ], + 'vsol' => [ + 'onu_list' => '1.3.6.1.2.1.155.1.4.1.5.1', + ], + 'huawei' => [ + 'gpon_onu' => '1.3.6.1.4.1.2011.5.104.1.1.1', + 'onu_sn' => '1.3.6.1.4.1.2011.6.128.1.1.2.43.1.3', + ], +]; + +private function createConnection(Olt $olt) { - /** - * @var array - */ - private array $connections = []; - - /** - * Connect to an OLT device. - */ - public function connect(int $oltId): bool - { - try { - $olt = Olt::findOrFail($oltId); - - if (! $olt->canConnect()) { - Log::warning("OLT {$oltId} cannot be connected: invalid configuration"); - - return false; - } - - // Close existing connection if any - if (isset($this->connections[$oltId])) { - $this->disconnect($oltId); - } - - $connection = $this->createConnection($olt); - - if (! $connection->login($olt->username, $olt->password)) { - Log::error("Failed to authenticate to OLT {$oltId}"); - - return false; - } - - $this->connections[$oltId] = $connection; + $protocol = $olt->management_protocol ?? 'ssh'; - Log::info("Successfully connected to OLT {$oltId}"); - - return true; - } catch (\Exception $e) { - Log::error("Error connecting to OLT {$oltId}: " . $e->getMessage()); - - return false; - } - } + return match ($protocol) { + 'ssh' => new SSH2($olt->ip_address, $olt->port ?: 22), + 'telnet' => new TelnetClient($olt->ip_address, $olt->port ?: 23), + default => throw new \RuntimeException("Unsupported protocol: {$protocol}"), + }; +} - /** - * Disconnect from an OLT device. - */ - public function disconnect(int $oltId): bool - { - if (isset($this->connections[$oltId])) { - $this->connections[$oltId]->disconnect(); - unset($this->connections[$oltId]); +public function testConnection(int $oltId): array +{ + $startTime = microtime(true); - Log::info("Disconnected from OLT {$oltId}"); + try { + $olt = Olt::findOrFail($oltId); - return true; + if (! $olt->canConnect()) { + return [ + 'success' => false, + 'message' => 'OLT configuration is invalid or incomplete', + 'latency' => 0, + ]; } - return false; - } + // SNMP quick check + if ($olt->management_protocol === 'snmp') { + $community = $olt->snmp_community ?: 'public'; + $ip = $olt->ip_address; + $port = $olt->snmp_port ?: 161; - /** - * Test connection to OLT. - */ - public function testConnection(int $oltId): array - { - $startTime = microtime(true); + $sysOid = '1.3.6.1.2.1.1.1.0'; + try { + $sys = @snmpget($ip . ':' . $port, $community, $sysOid); + if ($sys === false) { + return [ + 'success' => false, + 'message' => 'SNMP request failed', + 'latency' => (int) ((microtime(true) - $startTime) * 1000), + ]; + } - try { - $olt = Olt::findOrFail($oltId); + $latency = (int) ((microtime(true) - $startTime) * 1000); + $olt->update(['health_status' => 'healthy', 'last_health_check_at' => now()]); - if (! $olt->canConnect()) { - return [ - 'success' => false, - 'message' => 'OLT configuration is invalid or incomplete', - 'latency' => 0, - ]; + return ['success' => true, 'message' => 'SNMP OK', 'latency' => $latency]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'SNMP error: ' . $e->getMessage(), 'latency' => 0]; } + } - $connection = $this->createConnection($olt); + $connection = $this->createConnection($olt); + if ($olt->management_protocol === 'ssh') { + /** @var SSH2 $connection */ if (! $connection->login($olt->username, $olt->password)) { return [ 'success' => false, @@ -105,7 +82,6 @@ public function testConnection(int $oltId): array ]; } - // Get vendor-specific commands and test command execution $commands = $this->getVendorCommands($olt); $result = $connection->exec($commands['version']); $connection->disconnect(); @@ -113,1002 +89,128 @@ public function testConnection(int $oltId): array $latency = (int) ((microtime(true) - $startTime) * 1000); if ($result === false) { - return [ - 'success' => false, - 'message' => 'Command execution failed', - 'latency' => $latency, - ]; + return ['success' => false, 'message' => 'Command execution failed', 'latency' => $latency]; } - // Update health status - $olt->update([ - 'health_status' => 'healthy', - 'last_health_check_at' => now(), - ]); + $olt->update(['health_status' => 'healthy', 'last_health_check_at' => now()]); - return [ - 'success' => true, - 'message' => 'Connection successful', - 'latency' => $latency, - ]; - } catch (\Exception $e) { - Log::error("Error testing connection to OLT {$oltId}: " . $e->getMessage()); - - return [ - 'success' => false, - 'message' => $e->getMessage(), - 'latency' => (int) ((microtime(true) - $startTime) * 1000), - ]; + return ['success' => true, 'message' => 'SSH OK', 'latency' => $latency]; } - } - - /** - * Discover ONUs on the OLT. - */ - public function discoverOnus(int $oltId): array - { - $sshConnectionCreated = false; - - try { - $olt = Olt::findOrFail($oltId); - - // Try SNMP discovery first if SNMP is configured - $oltSnmpService = app(\App\Services\OltSnmpService::class); - - if ($olt->snmp_community && $olt->snmp_version) { - Log::info('Attempting SNMP-based ONU discovery', [ - 'olt_id' => $oltId, - 'vendor' => $olt->brand ?? $olt->model, - ]); - - $onus = $oltSnmpService->discoverOnusViaSNMP($olt); - - if (!empty($onus)) { - Log::info('Successfully discovered ONUs via SNMP', [ - 'olt_id' => $oltId, - 'count' => count($onus), - ]); - - return $onus; - } - - Log::warning('SNMP discovery returned no results, falling back to SSH', [ - 'olt_id' => $oltId, - ]); - } else { - Log::info('SNMP not configured, using SSH-based discovery', [ - 'olt_id' => $oltId, - ]); - } - - // Fallback to SSH-based discovery - $wasAlreadyConnected = isset($this->connections[$oltId]); - - if (! $this->ensureConnected($oltId)) { - throw new RuntimeException("Failed to connect to OLT {$oltId}"); - } - - $sshConnectionCreated = !$wasAlreadyConnected; - - $connection = $this->connections[$oltId]; - $commands = $this->getVendorCommands($olt); - $onus = []; - - // Execute ONU state command (vendor-specific) - $output = $connection->exec($commands['onu_state']); - - if ($output === false) { - throw new RuntimeException('Failed to execute discovery command'); - } - - Log::debug("ONU discovery output", ['output' => substr($output, 0, 500)]); - // Parse output based on vendor - $onus = $this->parseOnuListOutput($output, $olt); - - Log::info('Discovered ' . count($onus) . " ONUs on OLT {$oltId} via SSH"); - - return $onus; - } catch (\Exception $e) { - Log::error("Error discovering ONUs on OLT {$oltId}: " . $e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - // Only clean up connection if we created it in this method call - if ($sshConnectionCreated && isset($this->connections[$oltId])) { - $this->disconnect($oltId); + if ($olt->management_protocol === 'telnet') { + /** @var TelnetClient $connection */ + if (! $connection->connect()) { + return ['success' => false, 'message' => "Cannot connect to {$olt->ip_address}:{$olt->port}", 'latency' => 0]; } - return []; - } - } - - /** - * Check if OLT supports SNMP discovery. - */ - private function canUseSNMP(Olt $olt): bool - { - return !empty($olt->ip_address) - && !empty($olt->snmp_community) - && !empty($olt->snmp_version) - && in_array(strtolower($olt->management_protocol ?? ''), ['snmp', 'both']); - } - - /** - * Sync ONUs from OLT to database. - */ - public function syncOnus(int $oltId): int - { - try { - $olt = Olt::findOrFail($oltId); - - Log::info("Starting ONU sync for OLT {$oltId}", [ - 'olt_name' => $olt->name, - ]); - - $discoveredOnus = $this->discoverOnus($oltId); - - if (empty($discoveredOnus)) { - Log::warning("No ONUs discovered on OLT {$oltId}"); - return 0; + // Attempt login if credentials present + $banner = $connection->read(1024); + if (! empty($olt->username)) { + $connection->write($olt->username . "\r\n"); + usleep(200_000); + $connection->write($olt->password . "\r\n"); + usleep(200_000); } - - $syncedCount = 0; - $updatedCount = 0; - $createdCount = 0; - $errorCount = 0; - - // Process in batches to avoid memory issues with large OLT configurations - $batchSize = 100; - $batches = array_chunk($discoveredOnus, $batchSize); - - foreach ($batches as $batchIndex => $batch) { - Log::debug("Processing batch " . ($batchIndex + 1) . " of " . count($batches)); - - try { - DB::transaction(function () use ($olt, $batch, &$syncedCount, &$updatedCount, &$createdCount): void { - foreach ($batch as $onuData) { - try { - // Validate serial number - if (empty($onuData['serial_number']) || strlen($onuData['serial_number']) < 8) { - Log::warning("Skipping ONU with invalid serial number", [ - 'serial_number' => $onuData['serial_number'] ?? 'empty', - 'pon_port' => $onuData['pon_port'] ?? 'unknown', - ]); - continue; - } - - $onu = Onu::updateOrCreate( - [ - 'olt_id' => $olt->id, - 'serial_number' => $onuData['serial_number'], - ], - [ - 'pon_port' => $onuData['pon_port'], - 'onu_id' => $onuData['onu_id'], - 'status' => $onuData['status'], - 'signal_rx' => $onuData['signal_rx'] ?? null, - 'signal_tx' => $onuData['signal_tx'] ?? null, - 'distance' => $onuData['distance'] ?? null, - 'last_seen_at' => now(), - 'last_sync_at' => now(), - 'tenant_id' => $olt->tenant_id, - ] - ); - if ($onu->wasRecentlyCreated) { - $createdCount++; - } else { - $updatedCount++; - } - - $syncedCount++; - } catch (\Exception $e) { - Log::error("Error syncing individual ONU", [ - 'serial_number' => $onuData['serial_number'] ?? 'unknown', - 'pon_port' => $onuData['pon_port'] ?? 'unknown', - 'olt_id' => $olt->id, - 'error' => $e->getMessage(), - ]); - // Continue with next ONU instead of failing entire batch - } - } - }); - } catch (\Exception $e) { - Log::error("Error processing batch " . ($batchIndex + 1), [ - 'error' => $e->getMessage(), - ]); - $errorCount++; - } - } - - Log::info("ONU sync completed for OLT {$oltId}", [ - 'synced' => $syncedCount, - 'created' => $createdCount, - 'updated' => $updatedCount, - 'errors' => $errorCount, - ]); - - return $syncedCount; - } catch (\Exception $e) { - Log::error("Error syncing ONUs from OLT {$oltId}: " . $e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - return 0; - } - } - - /** - * Get detailed ONU status. - */ - public function getOnuStatus(int $onuId): array - { - $sshConnectionCreated = false; - - try { - $onu = Onu::with('olt')->findOrFail($onuId); - - // Try SNMP first if configured - if ($this->canUseSNMP($onu->olt)) { - $snmpService = app(OltSnmpService::class); - - Log::info('Attempting SNMP-based ONU status retrieval', [ - 'onu_id' => $onuId, - 'olt_id' => $onu->olt_id, - ]); - - $snmpStatus = $snmpService->getOnuOpticalPower($onu); - - if ($snmpStatus['rx_power'] !== null || $snmpStatus['tx_power'] !== null) { - Log::debug('Retrieved ONU status via SNMP', [ - 'onu_id' => $onuId, - 'rx_power' => $snmpStatus['rx_power'], - 'tx_power' => $snmpStatus['tx_power'], - ]); - - return [ - 'status' => $onu->status, - 'signal_rx' => $snmpStatus['rx_power'], - 'signal_tx' => $snmpStatus['tx_power'], - 'distance' => $snmpStatus['distance'], - 'uptime' => null, - 'last_update' => now()->toIso8601String(), - 'method' => 'snmp', - ]; - } - - Log::warning('SNMP status retrieval returned no data, falling back to SSH', [ - 'onu_id' => $onuId, - ]); - } - - // Fallback to SSH-based status retrieval - $wasAlreadyConnected = isset($this->connections[$onu->olt_id]); - - if (! $this->ensureConnected($onu->olt_id)) { - Log::error("Failed to connect to OLT via SSH for ONU status", [ - 'onu_id' => $onuId, - 'olt_id' => $onu->olt_id, - ]); - throw new RuntimeException("Failed to connect to OLT {$onu->olt_id}"); - } - - $sshConnectionCreated = !$wasAlreadyConnected; - - $connection = $this->connections[$onu->olt_id]; - $commands = $this->getVendorCommands($onu->olt); - - // Execute ONU status command (vendor-specific) - $command = $this->replaceCommandPlaceholders($commands['onu_detail'], [ - 'port' => $onu->pon_port, - 'id' => $onu->onu_id, - ]); - $output = $connection->exec($command); - - if ($output === false) { - throw new RuntimeException('Failed to execute status command'); - } - - // Parse output (simplified - real implementation varies by vendor) - $status = [ - 'status' => $onu->status, - 'signal_rx' => $onu->signal_rx, - 'signal_tx' => $onu->signal_tx, - 'distance' => $onu->distance, - 'uptime' => null, - 'last_update' => now()->toIso8601String(), - 'method' => 'ssh', - ]; - - return $status; - } catch (\Exception $e) { - Log::error("Error getting ONU {$onuId} status: " . $e->getMessage()); - - // Only clean up connection if we created it in this method call - if ($sshConnectionCreated && isset($onu) && isset($onu->olt_id) && isset($this->connections[$onu->olt_id])) { - $this->disconnect($onu->olt_id); - } - - return [ - 'status' => 'unknown', - 'signal_rx' => null, - 'signal_tx' => null, - 'distance' => null, - 'uptime' => null, - 'last_update' => now()->toIso8601String(), - 'method' => 'error', - ]; - } - } - - /** - * Refresh ONU status from OLT. - */ - public function refreshOnuStatus(int $onuId): bool - { - try { - $status = $this->getOnuStatus($onuId); - - Onu::where('id', $onuId)->update([ - 'status' => $status['status'], - 'signal_rx' => $status['signal_rx'], - 'signal_tx' => $status['signal_tx'], - 'distance' => $status['distance'], - 'last_sync_at' => now(), - ]); - - return true; - } catch (\Exception $e) { - Log::error("Error refreshing ONU {$onuId} status: " . $e->getMessage()); - - return false; - } - } - - /** - * Authorize an ONU. - */ - public function authorizeOnu(int $onuId): bool - { - try { - $onu = Onu::with('olt')->findOrFail($onuId); - - if (! $this->ensureConnected($onu->olt_id)) { - throw new RuntimeException("Failed to connect to OLT {$onu->olt_id}"); - } - - $connection = $this->connections[$onu->olt_id]; - $commands = $this->getVendorCommands($onu->olt); + $out = $connection->exec('show version', 0.3); + $connection->disconnect(); - // Execute authorization command (vendor-specific) - $command = $this->replaceCommandPlaceholders($commands['authorize'], [ - 'port' => $onu->pon_port, - 'id' => $onu->onu_id, - ]); - $output = $connection->exec($command); + $latency = (int) ((microtime(true) - $startTime) * 1000); - if ($output === false) { - throw new RuntimeException('Failed to execute authorization command'); + if (empty($out)) { + return ['success' => false, 'message' => 'Telnet command failed or empty response', 'latency' => $latency]; } - $onu->update(['status' => 'online']); - - Log::info("Authorized ONU {$onuId}"); + $olt->update(['health_status' => 'healthy', 'last_health_check_at' => now()]); - return true; - } catch (\Exception $e) { - Log::error("Error authorizing ONU {$onuId}: " . $e->getMessage()); - - return false; + return ['success' => true, 'message' => 'Telnet OK', 'latency' => $latency]; } - } - - /** - * Unauthorize an ONU. - */ - public function unauthorizeOnu(int $onuId): bool - { - try { - $onu = Onu::with('olt')->findOrFail($onuId); - - if (! $this->ensureConnected($onu->olt_id)) { - throw new RuntimeException("Failed to connect to OLT {$onu->olt_id}"); - } - - $connection = $this->connections[$onu->olt_id]; - $commands = $this->getVendorCommands($onu->olt); - // Execute unauthorization command (vendor-specific) - $command = $this->replaceCommandPlaceholders($commands['unauthorize'], [ - 'port' => $onu->pon_port, - 'id' => $onu->onu_id, - ]); - $output = $connection->exec($command); - - if ($output === false) { - throw new RuntimeException('Failed to execute unauthorization command'); - } + return ['success' => false, 'message' => 'Unsupported management protocol', 'latency' => 0]; + } catch (\Exception $e) { + Log::error('Error testing OLT connection: ' . $e->getMessage()); - $onu->update(['status' => 'offline']); - - Log::info("Unauthorized ONU {$onuId}"); - - return true; - } catch (\Exception $e) { - Log::error("Error unauthorizing ONU {$onuId}: " . $e->getMessage()); - - return false; - } + return ['success' => false, 'message' => $e->getMessage(), 'latency' => 0]; } +} - /** - * Reboot an ONU. - */ - public function rebootOnu(int $onuId): bool - { - try { - $onu = Onu::with('olt')->findOrFail($onuId); - - if (! $this->ensureConnected($onu->olt_id)) { - throw new RuntimeException("Failed to connect to OLT {$onu->olt_id}"); - } - - $connection = $this->connections[$onu->olt_id]; - $commands = $this->getVendorCommands($onu->olt); - - // Execute reboot command (vendor-specific) - $command = $this->replaceCommandPlaceholders($commands['reboot'], [ - 'port' => $onu->pon_port, - 'id' => $onu->onu_id, - ]); - $output = $connection->exec($command); - - if ($output === false) { - throw new RuntimeException('Failed to execute reboot command'); - } - - Log::info("Rebooted ONU {$onuId}"); - - return true; - } catch (\Exception $e) { - Log::error("Error rebooting ONU {$onuId}: " . $e->getMessage()); +public function discoverOnus(int $oltId): array +{ + try { + $olt = Olt::findOrFail($oltId); - return false; + if ($olt->management_protocol === 'snmp') { + return $this->snmpDiscoverOnus($olt); } - } - /** - * Create backup of OLT configuration. - */ - public function createBackup(int $oltId): bool - { - $sshConnectionCreated = false; - - try { - $olt = Olt::findOrFail($oltId); - - $wasAlreadyConnected = isset($this->connections[$oltId]); - + // For SSH/Telnet we attempt vendor command output parsing. Keep original approach; this is a simplified stub. + if ($olt->management_protocol === 'ssh') { if (! $this->ensureConnected($oltId)) { - throw new RuntimeException("Failed to connect to OLT {$oltId}"); + throw new \RuntimeException("Failed to connect to OLT {$oltId}"); } - - $sshConnectionCreated = !$wasAlreadyConnected; - $connection = $this->connections[$oltId]; + $connection = $this->connections[$oltId] ?? $this->createConnection($olt); $commands = $this->getVendorCommands($olt); + $out = $connection->exec($commands['show_onus']); - // Execute backup command (vendor-specific) - $output = $connection->exec($commands['backup']); - - if ($output === false || empty($output)) { - throw new RuntimeException('Failed to retrieve configuration'); - } - - // Create backup directory if it doesn't exist - $backupDir = 'backups/olts/' . $oltId; - Storage::makeDirectory($backupDir); - - // Generate backup filename - $timestamp = now()->format('Y-m-d_His'); - $filename = "olt_{$oltId}_backup_{$timestamp}.cfg"; - $filepath = $backupDir . '/' . $filename; - - // Save backup - if (!Storage::put($filepath, $output)) { - Log::error("Failed to save backup file: {$filepath}"); - throw new RuntimeException("Failed to save backup file"); - } - - $fileSize = strlen($output); - - // Create backup record - OltBackup::create([ - 'olt_id' => $oltId, - 'file_path' => $filepath, - 'file_size' => $fileSize, - 'backup_type' => 'manual', - ]); - - // Update OLT last backup timestamp - $olt->update(['last_backup_at' => now()]); - - Log::info("Created backup for OLT {$oltId}: {$filename}", [ - 'size' => $fileSize, - 'path' => $filepath, - ]); - - return true; - } catch (\Exception $e) { - Log::error("Error creating backup for OLT {$oltId}: " . $e->getMessage()); - - // Only clean up connection if we created it in this method call - if ($sshConnectionCreated && isset($this->connections[$oltId])) { - $this->disconnect($oltId); - } - - return false; - } - } - - /** - * Get list of backups for OLT. - */ - public function getBackupList(int $oltId): array - { - try { - $backups = OltBackup::where('olt_id', $oltId) - ->orderBy('created_at', 'desc') - ->get(); - - return $backups->map(function (OltBackup $backup) { - return [ - 'id' => $backup->id, - 'file_path' => $backup->file_path, - 'file_size' => $backup->file_size, - 'backup_type' => $backup->backup_type, - 'created_at' => $backup->created_at->toIso8601String(), - ]; - })->toArray(); - } catch (\Exception $e) { - Log::error("Error getting backup list for OLT {$oltId}: " . $e->getMessage()); - - return []; - } - } - - /** - * Export backup and return file path. - */ - public function exportBackup(int $oltId, string $backupId): ?string - { - try { - $backup = OltBackup::where('olt_id', $oltId) - ->where('id', $backupId) - ->firstOrFail(); - - // Check if backup file exists in storage - if (! Storage::exists($backup->file_path)) { - Log::warning("Backup file not found: {$backup->file_path}"); - - return null; - } - - return storage_path('app/' . $backup->file_path); - } catch (\Exception $e) { - Log::error("Error exporting backup {$backupId} for OLT {$oltId}: " . $e->getMessage()); - - return null; - } - } - - /** - * Apply configuration to OLT. - */ - public function applyConfiguration(int $oltId, array $config): bool - { - try { - if (! $this->ensureConnected($oltId)) { - throw new RuntimeException("Failed to connect to OLT {$oltId}"); - } - - $connection = $this->connections[$oltId]; - - // Enter configuration mode - $connection->exec('configure terminal'); - - // Apply each configuration command - foreach ($config as $command) { - $output = $connection->exec($command); - - if ($output === false) { - throw new RuntimeException("Failed to execute command: {$command}"); - } - } - - // Save configuration - $connection->exec('save'); - $connection->exec('exit'); - - Log::info("Applied configuration to OLT {$oltId}"); - - return true; - } catch (\Exception $e) { - Log::error("Error applying configuration to OLT {$oltId}: " . $e->getMessage()); - - return false; - } - } - - /** - * Get OLT statistics. - */ - public function getOltStatistics(int $oltId): array - { - try { - $olt = Olt::withCount([ - 'onus', - 'onus as online_onus_count' => function ($query) { - $query->where('status', 'online'); - }, - 'onus as offline_onus_count' => function ($query) { - $query->where('status', 'offline'); - }, - ])->findOrFail($oltId); - - if (! $this->ensureConnected($oltId)) { - throw new RuntimeException("Failed to connect to OLT {$oltId}"); - } - - $connection = $this->connections[$oltId]; - - // Get system information (vendor-specific) - $output = $connection->exec('show system'); - - // Parse output for statistics (simplified) - return [ - 'uptime' => 0, // Would parse from output - 'temperature' => null, - 'cpu_usage' => null, - 'memory_usage' => null, - 'total_onus' => $olt->onus_count ?? 0, - 'online_onus' => $olt->online_onus_count ?? 0, - 'offline_onus' => $olt->offline_onus_count ?? 0, - ]; - } catch (\Exception $e) { - Log::error("Error getting statistics for OLT {$oltId}: " . $e->getMessage()); - - return [ - 'uptime' => 0, - 'temperature' => null, - 'cpu_usage' => null, - 'memory_usage' => null, - 'total_onus' => 0, - 'online_onus' => 0, - 'offline_onus' => 0, - ]; + // TODO: parse $out vendor-specific + return []; // implement parsing as needed } - } - /** - * Get port utilization. - */ - public function getPortUtilization(int $oltId): array - { - try { + if ($olt->management_protocol === 'telnet') { if (! $this->ensureConnected($oltId)) { - throw new RuntimeException("Failed to connect to OLT {$oltId}"); + throw new \RuntimeException("Failed to connect to OLT {$oltId}"); } - $connection = $this->connections[$oltId]; - - // Get port statistics (vendor-specific) - $output = $connection->exec('show interface statistics'); - - // Parse output (simplified - would parse actual OLT output) - return []; - } catch (\Exception $e) { - Log::error("Error getting port utilization for OLT {$oltId}: " . $e->getMessage()); + $telnet = $this->connections[$oltId] ?? $this->createConnection($olt); + $out = $telnet->exec('show onus'); + // TODO: parse $out vendor-specific return []; } - } - /** - * Get bandwidth usage statistics. - */ - public function getBandwidthUsage(int $oltId, string $period = 'hourly'): array - { - // This would typically query a time-series database or stored statistics - // For now, return empty array return []; - } + } catch (\Exception $e) { + Log::error('Error discovering ONUs: ' . $e->getMessage()); - /** - * Create SSH connection to OLT. - */ - private function createConnection(Olt $olt): SSH2 - { - $connection = new SSH2($olt->ip_address, $olt->port); - $connection->setTimeout(30); - - return $connection; - } - - /** - * Ensure connection to OLT is established. - */ - private function ensureConnected(int $oltId): bool - { - if (! isset($this->connections[$oltId])) { - return $this->connect($oltId); - } - - return true; - } - - /** - * Get vendor-specific commands based on OLT model and name. - */ - private function getVendorCommands(Olt $olt): array - { - // Use centralized vendor detection - $vendor = \App\Helpers\OltVendorDetector::detect($olt); - - // Get commands based on detected vendor - return match ($vendor) { - 'vsol' => $this->getVsolCommands(), - 'huawei' => $this->getHuaweiCommands(), - 'zte' => $this->getZteCommands(), - 'fiberhome' => $this->getFiberhomeCommands(), - 'bdcom' => $this->getBdcomCommands(), - default => $this->getHuaweiCommands(), // Default to Huawei-style commands (most common) - }; - } - - /** - * Replace command placeholders with actual values. - */ - private function replaceCommandPlaceholders(string $command, array $params): string - { - $replacements = [ - '{port}' => $params['port'] ?? '', - '{id}' => $params['id'] ?? '', - '{slot}' => $params['slot'] ?? '0', - ]; - - return str_replace(array_keys($replacements), array_values($replacements), $command); - } - - /** - * Get VSOL-specific commands. - */ - private function getVsolCommands(): array - { - return [ - 'version' => 'show version', - 'onu_list' => 'show gpon onu-list', - 'onu_state' => 'show gpon onu state', - 'onu_detail' => 'show gpon onu detail gpon-onu_{port}:{id}', - 'authorize' => 'gpon onu authorize gpon-onu_{port}:{id}', - 'unauthorize' => 'no gpon onu authorize gpon-onu_{port}:{id}', - 'reboot' => 'gpon onu reboot gpon-onu_{port}:{id}', - 'backup' => 'show running-config', - ]; - } - - /** - * Get Huawei-specific commands. - */ - private function getHuaweiCommands(): array - { - return [ - 'version' => 'display version', - 'onu_list' => 'display ont info summary all', - 'onu_state' => 'display ont info 0 all', - 'onu_detail' => 'display ont info {slot} {port} {id}', - 'authorize' => 'ont confirm {slot} {port} ontid {id}', - 'unauthorize' => 'undo ont {slot} {port} {id}', - 'reboot' => 'ont reset {slot} {port} {id}', - 'backup' => 'display current-configuration', - ]; - } - - /** - * Get ZTE-specific commands. - */ - private function getZteCommands(): array - { - return [ - 'version' => 'show version', - 'onu_list' => 'show gpon onu uncfg', - 'onu_state' => 'show pon onu-info', - 'onu_detail' => 'show gpon onu detail-info gpon-onu_{port}:{id}', - 'authorize' => 'interface gpon-onu_{port}:{id}', - 'unauthorize' => 'no interface gpon-onu_{port}:{id}', - 'reboot' => 'pon-onu-mng gpon-onu_{port}:{id} reboot', - 'backup' => 'show running-config', - ]; - } - - /** - * Get Fiberhome-specific commands. - */ - private function getFiberhomeCommands(): array - { - return [ - 'version' => 'show version', - 'onu_list' => 'show onu-list', - 'onu_state' => 'show onu state', - 'onu_detail' => 'show onu detail-info onu-index {port}:{id}', - 'authorize' => 'onu add {port} {id}', - 'unauthorize' => 'onu delete {port} {id}', - 'reboot' => 'onu reboot {port} {id}', - 'backup' => 'show running-config', - ]; - } - - /** - * Get BDCOM-specific commands. - */ - private function getBdcomCommands(): array - { - return [ - 'version' => 'show version', - 'onu_list' => 'show epon onu-list', - 'onu_state' => 'show epon onu-info', - 'onu_detail' => 'show epon onu-detail epon-onu_{port}:{id}', - 'authorize' => 'epon onu authorize epon-onu_{port}:{id}', - 'unauthorize' => 'no epon onu authorize epon-onu_{port}:{id}', - 'reboot' => 'epon onu reboot epon-onu_{port}:{id}', - 'backup' => 'show running-config', - ]; - } - - /** - * Destructor to clean up connections. - */ - public function __destruct() - { - foreach (array_keys($this->connections) as $oltId) { - $this->disconnect($oltId); - } + return []; } +} - /** - * Parse ONU list output based on vendor format. - */ - private function parseOnuListOutput(string $output, Olt $olt): array - { - $vendor = \App\Helpers\OltVendorDetector::detect($olt); - $onus = []; - $lines = explode("\n", $output); - - foreach ($lines as $line) { - $line = trim($line); - if (empty($line)) { - continue; - } - - $onuData = match ($vendor) { - 'vsol' => $this->parseVsolOnuLine($line), - 'huawei' => $this->parseHuaweiOnuLine($line), - 'zte' => $this->parseZteOnuLine($line), - 'fiberhome' => $this->parseFiberhomeOnuLine($line), - default => $this->parseGenericOnuLine($line), - }; - - if ($onuData) { - $onus[] = $onuData; +private function snmpDiscoverOnus(Olt $olt): array +{ + $ip = $olt->ip_address; + $community = $olt->snmp_community ?: 'public'; + $port = $olt->snmp_port ?: 161; + $results = []; + + // BDCOM discovery using provided OIDs + $bdcomStatusOid = self::VENDOR_OIDS['bdcom']['onu_status']; + $bdcomMacOid = self::VENDOR_OIDS['bdcom']['onu_mac_sn']; + + try { + $statusEntries = @snmprealwalk($ip . ':' . $port, $community, $bdcomStatusOid); + $macEntries = @snmprealwalk($ip . ':' . $port, $community, $bdcomMacOid); + + if (is_array($macEntries)) { + foreach ($macEntries as $oid => $val) { + $index = (int) substr($oid, strrpos($oid, '.') + 1); + $mac = trim(str_replace('"', '', $val)); + $statusKey = $bdcomStatusOid . '.' . $index; + $status = $statusEntries[$statusKey] ?? null; + + $results[] = [ + 'serial_number' => $mac, + 'onu_id' => $index, + 'status' => ($status == 1) ? 'online' : 'offline', + 'pon_port' => 'unknown', + ]; } } - - return $onus; - } - - /** - * Parse VSOL ONU output line. - */ - private function parseVsolOnuLine(string $line): ?array - { - // VSOL format: gpon-onu_1/1:1 HWTC12345678 online 0/1/1 1 - if (preg_match('/gpon-onu[_-](\d+\/\d+):(\d+)\s+([A-Z0-9]+)\s+(\w+)/', $line, $matches)) { - return [ - 'pon_port' => $matches[1], - 'onu_id' => (int) $matches[2], - 'serial_number' => $matches[3], - 'status' => strtolower($matches[4]), - 'signal_rx' => null, - 'signal_tx' => null, - 'distance' => null, - ]; - } - - return null; - } - - /** - * Parse Huawei ONU output line. - */ - private function parseHuaweiOnuLine(string $line): ?array - { - // Huawei format: 0/1/1 1 HWTC12345678 online ... - if (preg_match('/(\d+\/\d+\/\d+)\s+(\d+)\s+([A-Z0-9]+)\s+(\w+)/', $line, $matches)) { - return [ - 'pon_port' => $matches[1], - 'onu_id' => (int) $matches[2], - 'serial_number' => $matches[3], - 'status' => strtolower($matches[4]) === 'online' ? 'online' : 'offline', - 'signal_rx' => null, - 'signal_tx' => null, - 'distance' => null, - ]; - } - - return null; + } catch (\Throwable $e) { + Log::warning('SNMP BDCOM discovery failed: ' . $e->getMessage()); } - /** - * Parse ZTE ONU output line. - */ - private function parseZteOnuLine(string $line): ?array - { - // ZTE format: gpon-onu_1/1:1 ZTEG12345678 Working ... - if (preg_match('/gpon-onu[_-](\d+\/\d+):(\d+)\s+([A-Z0-9]+)\s+(\w+)/', $line, $matches)) { - return [ - 'pon_port' => $matches[1], - 'onu_id' => (int) $matches[2], - 'serial_number' => $matches[3], - 'status' => strtolower($matches[4]) === 'working' ? 'online' : 'offline', - 'signal_rx' => null, - 'signal_tx' => null, - 'distance' => null, - ]; - } - - return null; - } - - /** - * Parse Fiberhome ONU output line. - */ - private function parseFiberhomeOnuLine(string $line): ?array - { - // Fiberhome format: 1/1 1 FHTT12345678 online ... - if (preg_match('/(\d+\/\d+)\s+(\d+)\s+([A-Z0-9]+)\s+(\w+)/', $line, $matches)) { - return [ - 'pon_port' => $matches[1], - 'onu_id' => (int) $matches[2], - 'serial_number' => $matches[3], - 'status' => strtolower($matches[4]), - 'signal_rx' => null, - 'signal_tx' => null, - 'distance' => null, - ]; - } - - return null; - } - - /** - * Parse generic ONU output line (fallback). - */ - private function parseGenericOnuLine(string $line): ?array - { - // Generic format: port/path id serial status - if (preg_match('/(\d+[\/\-]\d+(?:[\/\-]\d+)?)[:\s]+(\d+)\s+([A-Z0-9]{8,})\s+(\w+)/', $line, $matches)) { - return [ - 'pon_port' => str_replace('-', '/', $matches[1]), - 'onu_id' => (int) $matches[2], - 'serial_number' => $matches[3], - 'status' => strtolower($matches[4]), - 'signal_rx' => null, - 'signal_tx' => null, - 'distance' => null, - ]; - } - - return null; - } -} + // V-SOL and Huawei OID parsing would be similar (use provided OIDs) + return $results; +} \ No newline at end of file diff --git a/app/Services/Transport/TelnetClient.php b/app/Services/Transport/TelnetClient.php new file mode 100644 index 00000000..56679f12 --- /dev/null +++ b/app/Services/Transport/TelnetClient.php @@ -0,0 +1,72 @@ +host = $host; + $this->port = $port; + $this->timeout = $timeout; + } + + public function connect(): bool + { + $this->resource = @stream_socket_client(sprintf('tcp://%s:%d', $this->host, $this->port), $errno, $errstr, $this->timeout); + + if (! $this->resource) { + return false; + } + + stream_set_timeout($this->resource, $this->timeout); + + // Read initial banner if any + $this->read(1024); + + return true; + } + + public function disconnect(): void + { + if (is_resource($this->resource)) { + fclose($this->resource); + } + + $this->resource = null; + } + + public function read(int $len = 4096): string + { + if (! is_resource($this->resource)) { + return ''; + } + + return stream_get_contents($this->resource, $len) ?: ''; + } + + public function write(string $data): bool + { + if (! is_resource($this->resource)) { + return false; + } + + fwrite($this->resource, $data); + + return true; + } + + public function exec(string $cmd, float $wait = 0.2): string + { + $this->write($cmd . "\r\n"); + usleep((int) ($wait * 1_000_000)); + return $this->read(); + } +} \ No newline at end of file diff --git a/database/migrations/2026_02_02_100000_add_olt_ports_and_snmp_port.php b/database/migrations/2026_02_02_100000_add_olt_ports_and_snmp_port.php new file mode 100644 index 00000000..5c6c413a --- /dev/null +++ b/database/migrations/2026_02_02_100000_add_olt_ports_and_snmp_port.php @@ -0,0 +1,35 @@ +integer('port')->default(22)->after('ip_address'); + } + + if (! Schema::hasColumn('olts', 'snmp_port')) { + $table->integer('snmp_port')->nullable()->default(161)->after('port'); + } + + if (! Schema::hasColumn('olts', 'management_protocol')) { + $table->string('management_protocol', 20)->default('ssh')->after('port'); + } + }); + } + + public function down(): void + { + Schema::table('olts', function (Blueprint $table) { + if (Schema::hasColumn('olts', 'snmp_port')) { + $table->dropColumn('snmp_port'); + } + // Keep 'port' and 'management_protocol' if they exist in prior migrations to avoid accidental drops. + }); + } +}; \ No newline at end of file diff --git a/resources/views/panels/admin/network/olt-create.blade.php b/resources/views/panels/admin/network/olt-create.blade.php index 02f2871d..3a0901d3 100644 --- a/resources/views/panels/admin/network/olt-create.blade.php +++ b/resources/views/panels/admin/network/olt-create.blade.php @@ -1,207 +1,21 @@ -@extends('panels.layouts.app') - -@section('title', 'Add New OLT Device') - -@section('content') -
- -
-
-
-
-

Add New OLT Device

-

Configure a new Optical Line Terminal

-
- - - - - Back to List - -
-
+
+ + + +
+ +
- -
-
- @csrf - -
-

Basic Information

-
-
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
- - -
-

Network Configuration

-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
+
+ + -
- - -
- -
- - -
- -
- - -
-
-
- - -
-

Location & Details

-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
- - -
- - Cancel - - - -
- + +
-
- -@push('scripts') - -@endpush -@endsection +
\ No newline at end of file diff --git a/tests/Feature/OltProtocolConnectionTest.php b/tests/Feature/OltProtocolConnectionTest.php new file mode 100644 index 00000000..1d023471 --- /dev/null +++ b/tests/Feature/OltProtocolConnectionTest.php @@ -0,0 +1,64 @@ +create([ + 'management_protocol' => 'snmp', + 'snmp_community' => 'public', + 'snmp_port' => 161, + 'ip_address' => '127.0.0.1', // in CI tests you should mock SNMP or run against test SNMP agent + ]); + + $service = app(OltService::class); + + $result = $service->testConnection($olt->id); + + $this->assertIsArray($result); + $this->assertArrayHasKey('success', $result); + } + + public function test_ssh_connection_returns_array(): void + { + $olt = Olt::factory()->create([ + 'management_protocol' => 'ssh', + 'port' => 22, + 'username' => 'admin', + 'password' => 'password', + ]); + + $service = app(OltService::class); + + $result = $service->testConnection($olt->id); + + $this->assertIsArray($result); + $this->assertArrayHasKey('success', $result); + } + + public function test_telnet_connection_returns_array(): void + { + $olt = Olt::factory()->create([ + 'management_protocol' => 'telnet', + 'port' => 23, + ]); + + $service = app(OltService::class); + + $result = $service->testConnection($olt->id); + + $this->assertIsArray($result); + $this->assertArrayHasKey('success', $result); + } +} \ No newline at end of file