|null */ public ?array $data = []; public ?string $fromCommit = null; public ?string $toCommit = null; public ?string $gitError = null; /** @var array a felületen kézzel kivett fájlok */ public array $skippedPaths = []; /** @var array|null az utoljára elkészített csomag adatai */ public ?array $lastPackage = null; /** @var array> targetenként a szerverről lekérdezett élő verzió */ public array $liveVersions = []; public static function canAccess(): bool { return app(DeploymentGuard::class)->isAllowed(auth()->user()); } public function mount(): void { app(DeploymentGuard::class)->ensureAllowed(auth()->user()); /** @var array $limits */ $limits = (array) config('deployment.commit_limits', [50]); $target = app(DeploymentTargets::class)->defaultKey(); $this->form->fill([ 'target' => $target, 'branch' => $this->defaultBranch(), 'limit' => $limits[0] ?? 50, 'search' => null, ]); $this->preloadFromCommit($target); $this->fetchAllLiveVersions(); } /** * A kezdő commit előtöltése az adott környezetre utoljára kirakott csomagból. * * Ez a funkció lényegi része: a "hol tart ez a környezet" kérdésre nem emlékezetből * kell válaszolni, így nem marad ki fájl a következő csomagból. */ private function preloadFromCommit(?string $targetKey): void { if (! $targetKey) { return; } $lastDeployed = DeploymentPackageRecord::lastDeployedFor($targetKey); // Rebase/force push után a korábban rögzített hash már nem létezik - ilyenkor // inkább nem töltünk elő semmit, mint hogy hamis tartományt mutassunk. $this->fromCommit = $lastDeployed ? app(GitRepository::class)->resolveCommit($lastDeployed->to_commit) : null; } public function form(Schema $form): Schema { /** @var array $limits */ $limits = (array) config('deployment.commit_limits', [50]); return $form ->schema([ Grid::make(4) ->schema([ Select::make('target') ->label('Célkörnyezet') ->options(app(DeploymentTargets::class)->options()) ->required() ->live() ->afterStateUpdated(fn (?string $state) => $this->preloadFromCommit($state)) ->helperText(fn (): ?string => $this->target()['remote_path'] ?? null), Select::make('branch') ->label('Branch') ->options($this->branchOptions()) ->required() ->searchable() ->live() ->afterStateUpdated(fn () => $this->clearRange()), Select::make('limit') ->label('Commitok száma') ->options(array_combine($limits, $limits)) ->required() ->live(), TextInput::make('search') ->label('Keresés') ->placeholder('pl. árlista, migráció, szerző') ->helperText('A commit üzenetében (a törzsben is), a szerzőben és a hashben keres, a betöltött listán belül.') ->live(debounce: 400), ]), ]) ->statePath('data'); } public function selectFrom(string $hash): void { app(DeploymentGuard::class)->ensureAllowed(auth()->user()); $this->fromCommit = $this->resolveOrWarn($hash); } public function selectTo(string $hash): void { app(DeploymentGuard::class)->ensureAllowed(auth()->user()); $this->toCommit = $this->resolveOrWarn($hash); } public function swapRange(): void { app(DeploymentGuard::class)->ensureAllowed(auth()->user()); [$this->fromCommit, $this->toCommit] = [$this->toCommit, $this->fromCommit]; } public function clearRange(): void { $this->fromCommit = null; $this->toCommit = null; $this->skippedPaths = []; } /** * Egy fájl kézi ki-/visszavétele a csomagból. */ public function toggleFile(string $path): void { app(DeploymentGuard::class)->ensureAllowed(auth()->user()); $this->skippedPaths = in_array($path, $this->skippedPaths, true) ? array_values(array_diff($this->skippedPaths, [$path])) : array_merge($this->skippedPaths, [$path]); } /** * A csomag előállítása a kijelölt tartományból. */ public function createPackage(): void { app(DeploymentGuard::class)->ensureAllowed(auth()->user()); if (! $this->fromCommit || ! $this->toCommit) { Notification::make() ->title('Nincs kijelölt tartomány') ->body('Válassz egy kezdő és egy záró commitot.') ->danger() ->send(); return; } try { $this->lastPackage = app(DeploymentPackageBuilder::class)->build( (string) ($this->data['target'] ?? ''), (string) ($this->data['branch'] ?? ''), $this->fromCommit, $this->toCommit, $this->skippedPaths, auth()->user()?->email, ); } catch (RuntimeException $exception) { Notification::make() ->title('A csomag nem készült el') ->body($exception->getMessage()) ->danger() ->persistent() ->send(); return; } DeploymentPackageRecord::create([ 'name' => $this->lastPackage['name'], 'target' => $this->lastPackage['target'], 'branch' => $this->lastPackage['branch'], 'from_commit' => $this->lastPackage['from'], 'to_commit' => $this->lastPackage['to'], 'folder' => $this->lastPackage['path'], 'zip_path' => $this->lastPackage['zip'], 'copied_count' => $this->lastPackage['counts']['copied'], 'deleted_count' => $this->lastPackage['counts']['deleted'], 'skipped_count' => $this->lastPackage['counts']['skipped'], 'missing_count' => $this->lastPackage['counts']['missing'], ]); Notification::make() ->title('A csomag elkészült') ->body(sprintf( '%d fájl a(z) %s mappában.', $this->lastPackage['counts']['copied'], $this->lastPackage['name'], )) ->success() ->send(); } /** * A csomag ZIP-jének letöltése. */ public function downloadPackage(int $id): ?BinaryFileResponse { app(DeploymentGuard::class)->ensureAllowed(auth()->user()); $package = DeploymentPackageRecord::find($id); $zipPath = $package?->zip_path; // Az útvonal adatbázisból jön, ezért kiírás előtt ellenőrizzük, hogy a csomagoló // saját kimeneti mappáján belül van - egy elrontott vagy módosított rekord így sem // tud tetszőleges fájlt letölthetővé tenni. if (! $zipPath || ! is_file($zipPath) || ! $this->isInsideOutputPath($zipPath)) { Notification::make() ->title('A ZIP nem érhető el') ->body('A csomag ZIP fájlja már nincs a lemezen.') ->danger() ->send(); return null; } return response()->download($zipPath); } /** * A csomag megjelölése kirakottként - innentől ez lesz a következő tartomány kezdete. */ public function markDeployed(int $id): void { app(DeploymentGuard::class)->ensureAllowed(auth()->user()); $package = DeploymentPackageRecord::find($id); if (! $package) { return; } $package->deployed_at = now(); $package->save(); $this->preloadFromCommit($package->target); Notification::make() ->title('Megjelölve kirakottként') ->body(sprintf('A(z) %s környezet innentől a %s commiton áll.', $package->target, substr((string) $package->to_commit, 0, 7))) ->success() ->send(); } /** * Az élő állapot lekérdezése a célkörnyezetről (deploy-version.json). * * Szándékosan gombra fut és nem minden rendereléskor: így nem indul kimenő kérés * magától, és nem lassítja az oldalt, ha egy környezet nem válaszol. */ public function fetchLiveVersion(string $targetKey, bool $force = false): void { app(DeploymentGuard::class)->ensureAllowed(auth()->user()); $target = app(DeploymentTargets::class)->find($targetKey); if (! $target || ! $target['domain']) { return; } $cacheKey = 'deployment.live-version.'.$targetKey; if ($force) { Cache::forget($cacheKey); } // Rövid cache: az oldal minden Livewire körben újrarenderel, kérésenkénti // HTTP hívás nélkül. A hibás választ is cache-eljük, hogy egy nem válaszoló // környezet ne lassítsa minden kattintásnál a felületet. $this->liveVersions[$targetKey] = Cache::remember($cacheKey, now()->addMinute(), function () use ($target): array { try { $response = Http::connectTimeout(2)->timeout(3)->get('https://'.$target['domain'].'/deploy-version.json'); return $response->successful() ? ['ok' => true, 'data' => $response->json()] : ['ok' => false, 'error' => 'HTTP '.$response->status()]; } catch (Throwable $exception) { return ['ok' => false, 'error' => $exception->getMessage()]; } }); } /** * Minden környezet állapotának lekérése (oldalbetöltéskor fut). */ public function fetchAllLiveVersions(bool $force = false): void { foreach (array_keys(app(DeploymentTargets::class)->all()) as $targetKey) { $this->fetchLiveVersion($targetKey, $force); } } /** * Melyik commiton áll melyik környezet - a commitlista megjelöléséhez. * * Elsődlegesen a szerverről lekérdezett élő állapotot használjuk; ha az nem * elérhető, a naplóban kirakottként megjelölt csomagot. * * @return array> */ #[Computed] public function commitMarkers(): array { $markers = []; foreach ($this->environmentStatuses() as $status) { $liveCommit = ($status['live']['ok'] ?? false) ? ($status['live']['data']['commit'] ?? null) : null; $commit = $liveCommit ?: $status['package']?->to_commit; if (! $commit) { continue; } $markers[$commit][] = [ 'key' => $status['target']['key'], 'label' => $status['target']['label'], 'live' => (bool) $liveCommit, ]; } return $markers; } /** * Környezetenkénti állapot: mi van kirakva és mennyi a lemaradás. * * @return array> */ #[Computed] public function environmentStatuses(): array { $branch = $this->data['branch'] ?? null; $statuses = []; foreach (app(DeploymentTargets::class)->all() as $key => $target) { $lastDeployed = DeploymentPackageRecord::lastDeployedFor($key); $live = $this->liveVersions[$key] ?? null; $liveCommit = $live['ok'] ?? false ? ($live['data']['commit'] ?? null) : null; $statuses[] = [ 'target' => $target, 'package' => $lastDeployed, 'behind' => $lastDeployed && $branch ? app(GitRepository::class)->countCommitsBetween($lastDeployed->to_commit, $branch) : null, 'live' => $live, // A napló azt mondja meg, mit jelöltünk kirakottnak; a szerver azt, mi fut // valójában. Ha a kettő eltér, az elmaradt vagy félbemaradt feltöltés jele. 'mismatch' => $liveCommit && $lastDeployed && $liveCommit !== $lastDeployed->to_commit, ]; } return $statuses; } /** * @return \Illuminate\Support\Collection */ #[Computed] public function recentPackages(): Collection { return DeploymentPackageRecord::query() ->when($this->data['target'] ?? null, fn ($query, $target) => $query->forTarget($target)) ->latest('id') ->limit(10) ->get(); } private function isInsideOutputPath(string $path): bool { $base = realpath((string) config('deployment.output_path')); $real = realpath($path); return $base !== false && $real !== false && str_starts_with($real, $base); } /** * @return array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string}|null */ public function target(): ?array { return app(DeploymentTargets::class)->find($this->data['target'] ?? null); } /** * @return array */ #[Computed] public function commits(): array { $branch = $this->data['branch'] ?? null; if (! $branch) { return []; } try { $commits = app(GitRepository::class)->commits($branch, (int) ($this->data['limit'] ?? 50)); } catch (RuntimeException $exception) { $this->gitError = $exception->getMessage(); return []; } $search = trim((string) ($this->data['search'] ?? '')); if ($search === '') { return $commits; } $needle = mb_strtolower($search); return array_values(array_filter( $commits, fn (array $commit): bool => str_contains( mb_strtolower(implode(' ', [$commit['subject'], $commit['body'], $commit['author'], $commit['hash']])), $needle, ), )); } /** * A kijelölt tartomány elemzése, vagy null, ha még nincs két commit kiválasztva. * * @return array{ * files: array, * copied: array, * deleted: array, * counts: array{total:int, copied:int, deleted:int, excluded:int}, * warnings: array * }|null */ #[Computed] public function changeSet(): ?array { if (! $this->fromCommit || ! $this->toCommit) { return null; } $repository = app(GitRepository::class); try { $changedFiles = $repository->changedFiles($this->fromCommit, $this->toCommit); } catch (RuntimeException $exception) { $this->gitError = $exception->getMessage(); return null; } $changeSet = app(ChangeSetAnalyzer::class)->analyze( $changedFiles, $this->target(), $this->data['branch'] ?? null, ); if ($this->fromCommit === $this->toCommit) { array_unshift($changeSet['warnings'], [ 'level' => 'warning', 'title' => 'Azonos commitok', 'body' => 'A kezdő és a záró commit ugyanaz, így a tartomány üres.', ]); } elseif (! $repository->isAncestor($this->fromCommit, $this->toCommit)) { array_unshift($changeSet['warnings'], [ 'level' => 'danger', 'title' => 'Fordított vagy szétágazó tartomány', 'body' => 'A kezdő commit nem őse a zárónak, ezért a lista nem a "mi került bele azóta" kérdésre válaszol. Ellenőrizd a sorrendet (Csere gomb), vagy azt, hogy ugyanazon az ágon vagy-e.', ]); } return $changeSet; } /** * A kijelölt tartományba eső commitok - csak a lista kiemeléséhez. * * @return array */ #[Computed] public function highlightedHashes(): array { if (! $this->fromCommit || ! $this->toCommit) { return array_values(array_filter([$this->fromCommit, $this->toCommit])); } $hashes = array_column($this->commits(), 'hash'); $fromIndex = array_search($this->fromCommit, $hashes, true); $toIndex = array_search($this->toCommit, $hashes, true); if ($fromIndex === false || $toIndex === false) { return array_values(array_filter([$this->fromCommit, $this->toCommit])); } // A lista fentről lefelé a legfrissebbtől halad, tehát a záró commit van előrébb. [$start, $end] = $fromIndex <= $toIndex ? [$fromIndex, $toIndex] : [$toIndex, $fromIndex]; return array_slice($hashes, $start, $end - $start + 1); } /** * @return array */ public function branchOptions(): array { try { $branches = app(GitRepository::class)->branches(); } catch (RuntimeException $exception) { $this->gitError = $exception->getMessage(); return []; } return array_combine($branches, $branches) ?: []; } public function isRepositoryAvailable(): bool { return app(GitRepository::class)->isAvailable(); } private function defaultBranch(): ?string { $repository = app(GitRepository::class); $current = $repository->currentBranch(); if ($current) { return $current; } return $this->branchOptions() === [] ? null : array_key_first($this->branchOptions()); } private function resolveOrWarn(string $hash): ?string { $resolved = app(GitRepository::class)->resolveCommit($hash); if (! $resolved) { Notification::make() ->title('Ismeretlen commit') ->body('A kiválasztott commit nem található a repóban.') ->danger() ->send(); return null; } return $resolved; } }