scanJobRepository = $scanJobRepository; $this->networkScanner = $networkScanner; $this->networkRangeRepository = $networkRangeRepository; } /** * Run a single pending scan job. */ public function runNext(): ?ScanJob { $pending = $this->scanJobRepository->findPending(); if (empty($pending)) { return null; } $job = $pending[0]; $this->execute($job); return $job; } /** * Run all pending scan jobs in a loop. * * @param callable|null $onProgress Called after each job: fn(ScanJob $job) */ public function runAll(?callable $onProgress = null): int { $count = 0; while (true) { $job = $this->runNext(); if ($job === null) { break; } $count++; if ($onProgress !== null) { $onProgress($job); } } return $count; } public function execute(ScanJob $job): void { $job->status = 'running'; $job->startedAt = new \DateTimeImmutable(); $this->scanJobRepository->save($job); try { $finalStatus = null; switch ($job->type) { case 'network_discovery': $finalStatus = $this->runNetworkDiscovery($job); break; default: throw new \RuntimeException("Unknown scan job type: {$job->type}"); } if ($finalStatus === 'done') { $job->status = 'done'; $job->finishedAt = new \DateTimeImmutable(); } elseif ($finalStatus === 'paused') { $job->status = 'paused'; $job->finishedAt = null; } elseif ($finalStatus === 'cancelled') { $job->status = 'cancelled'; $job->finishedAt = new \DateTimeImmutable(); } } catch (\Throwable $e) { $job->status = 'failed'; $job->errorMessage = $e->getMessage(); $job->finishedAt = new \DateTimeImmutable(); } $this->scanJobRepository->save($job); } private function runNetworkDiscovery(ScanJob $job): string { if ($job->networkRangeId === null) { throw new \RuntimeException('Network discovery job has no network_range_id'); } $range = $this->networkRangeRepository->findById($job->networkRangeId); if ($range === null) { throw new \RuntimeException("Network range #{$job->networkRangeId} not found"); } if (!$range->enabled) { throw new \RuntimeException("Network range #{$job->networkRangeId} is disabled"); } $existingProgress = $this->decodeProgress($job); $startIndex = (int)($existingProgress['next_ip_index'] ?? 0); $previousHostsFound = (int)($existingProgress['hosts_found'] ?? 0); $result = $this->networkScanner->scanResumable( $range, $job->id ?? 0, $startIndex, function () use ($job): bool { $fresh = $this->scanJobRepository->findById((int)$job->id); return $fresh !== null && in_array($fresh->status, ['paused', 'cancelled'], true); }, function (array $state) use ($job, $range, $previousHostsFound): void { $fresh = $this->scanJobRepository->findById((int)$job->id); if ($fresh !== null && in_array($fresh->status, ['paused', 'cancelled'], true)) { $job->status = $fresh->status; } $job->resultJson = json_encode([ 'network_range_id' => $range->id, 'cidr' => $range->cidr, 'next_ip_index' => $state['next_ip_index'], 'total_ips' => $state['total_ips'], 'scanned_count' => $state['scanned_count'], 'hosts_found' => $previousHostsFound + $state['hosts_found'], ], JSON_THROW_ON_ERROR); $this->scanJobRepository->save($job); } ); $job->resultJson = json_encode([ 'network_range_id' => $range->id, 'cidr' => $range->cidr, 'next_ip_index' => $result['next_ip_index'], 'total_ips' => $result['total_ips'], 'scanned_count' => $result['scanned_count'], 'hosts_found' => $previousHostsFound + $result['hosts_found'], ], JSON_THROW_ON_ERROR); if ($result['completed']) { return 'done'; } $fresh = $this->scanJobRepository->findById((int)$job->id); if ($fresh !== null && in_array($fresh->status, ['paused', 'cancelled'], true)) { return $fresh->status; } return 'paused'; } private function decodeProgress(ScanJob $job): array { if ($job->resultJson === null || $job->resultJson === '') { return []; } $decoded = json_decode($job->resultJson, true); return is_array($decoded) ? $decoded : []; } }