socialPlatformCredentials() ->get() ->keyBy('platform'); $postStats = $user->socialPosts() ->selectRaw('platform, status, COUNT(*) as aggregate') ->groupBy('platform', 'status') ->get() ->groupBy('platform'); $approvalStats = $user->socialPosts() ->selectRaw('platform, COUNT(*) as aggregate') ->where('requires_approval', true) ->where('status', 'draft') ->groupBy('platform') ->pluck('aggregate', 'platform'); $lastActivity = $user->socialPosts() ->select(['platform', 'status', 'scheduled_at', 'posted_at', 'updated_at']) ->latest('updated_at') ->get() ->groupBy('platform') ->map(fn ($items) => $items->first()); return collect($platforms)->mapWithKeys(function (string $label, string $platform) use ($credentials, $postStats, $approvalStats, $lastActivity): array { $credential = $credentials->get($platform); $statusCounts = collect($postStats->get($platform, collect())) ->pluck('aggregate', 'status'); $counts = [ 'draft' => (int) ($statusCounts->get('draft') ?? 0), 'scheduled' => (int) ($statusCounts->get('scheduled') ?? 0), 'posted' => (int) ($statusCounts->get('posted') ?? 0), 'failed' => (int) ($statusCounts->get('failed') ?? 0), 'approval' => (int) ($approvalStats->get($platform) ?? 0), ]; $hasToken = filled($credential?->access_token); $hasIdentity = filled($credential?->account_id) || filled($credential?->account_label); $hasLabel = filled($credential?->account_label); $hasQueue = array_sum($counts) > 0; $hasSuccessfulPost = $counts['posted'] > 0; $steps = [ ['label' => 'Access token connected', 'done' => $hasToken, 'weight' => 45], ['label' => 'Account identity saved', 'done' => $hasIdentity, 'weight' => 20], ['label' => 'Brand label attached', 'done' => $hasLabel, 'weight' => 10], ['label' => 'Queue item prepared', 'done' => $hasQueue, 'weight' => 15], ['label' => 'Successful publish recorded', 'done' => $hasSuccessfulPost, 'weight' => 10], ]; $completion = (int) collect($steps) ->filter(fn (array $step) => $step['done']) ->sum('weight'); $last = $lastActivity->get($platform); $lastActivityAt = collect([ $last?->posted_at, $last?->scheduled_at, $last?->updated_at, ])->filter()->first(); return [ $platform => [ 'platform' => $platform, 'label' => $label, 'completion' => $completion, 'state' => $this->platformState($completion, $counts), 'state_tone' => $this->platformStateTone($completion, $counts), 'credential_ready' => $hasToken, 'counts' => $counts, 'next_action' => $this->nextAction($hasToken, $hasQueue, $hasSuccessfulPost, $counts), 'last_activity_at' => $lastActivityAt instanceof Carbon ? $lastActivityAt : null, 'last_status' => $last?->status, 'steps' => collect($steps) ->map(fn (array $step) => [ 'label' => $step['label'], 'done' => $step['done'], ]) ->all(), ], ]; })->all(); } public function cronStatus(): array { $expectedCommand = sprintf( '* * * * * cd %s && php artisan schedule:run >> /dev/null 2>&1', base_path() ); $runnerCommand = 'php artisan social:publish-scheduled --limit=30'; $isHuggingFaceSpace = $this->isHuggingFaceSpace(); $serviceOutput = trim((string) $this->safeShell('systemctl is-active cron 2>/dev/null')); $serviceStatus = in_array($serviceOutput, ['active', 'inactive', 'failed'], true) ? $serviceOutput : 'unknown'; $scheduleEntry = $this->detectScheduleEntry(); $processOutput = trim((string) $this->safeShell("ps -eo args 2>/dev/null | grep -E 'artisan (schedule:work|social:publish-scheduled|queue:work|queue:listen)' | grep -v grep")); $processRunning = $processOutput !== ''; [$state, $headline, $detail] = match (true) { $isHuggingFaceSpace && $processRunning => [ 'warning', 'Managed container runner', 'Space ini jalan di container Hugging Face, jadi cron Linux tidak dijamin tersedia. Saat ini ada proses artisan aktif sebagai fallback runner.', ], $isHuggingFaceSpace => [ 'warning', 'Managed container without cron', 'Hugging Face Spaces tidak menyediakan cron Linux yang stabil seperti VPS biasa, jadi scheduled publish butuh trigger eksternal atau worker terpisah.', ], $serviceStatus !== 'active' => [ 'critical', 'Cron service inactive', 'Scheduler Laravel belum akan jalan otomatis karena service cron di server masih mati.', ], $scheduleEntry['found'] => [ 'healthy', 'Cron scheduler connected', 'Cron Linux aktif dan entry Laravel scheduler sudah ditemukan, jadi publish terjadwal bisa dieksekusi otomatis.', ], $processRunning => [ 'warning', 'Manual runner detected', 'Belum ada entry cron Laravel yang jelas, tapi ada proses artisan yang sedang berjalan sebagai fallback manual.', ], default => [ 'warning', 'Cron entry missing', 'Service cron aktif, tapi entry `schedule:run` belum ditemukan sehingga automation berisiko tidak pernah mengeksekusi scheduled publish.', ], }; return [ 'state' => $state, 'headline' => $headline, 'detail' => $detail, 'service_status' => $serviceStatus, 'schedule_entry_found' => $scheduleEntry['found'], 'schedule_entry_source' => $scheduleEntry['source'], 'runner_detected' => $processRunning, 'runner_command' => $processOutput !== '' ? $processOutput : null, 'expected_cron' => $expectedCommand, 'publish_command' => $runnerCommand, 'managed_runtime' => $isHuggingFaceSpace ? 'huggingface_spaces' : null, 'checked_at' => now(), ]; } private function detectScheduleEntry(): array { $shellDetected = $this->detectScheduleEntryViaShell(); if ($shellDetected['found']) { return $shellDetected; } $candidates = array_filter(array_merge( ['/etc/crontab'], glob('/etc/cron.d/*') ?: [], glob('/var/spool/cron/crontabs/*') ?: [] )); foreach ($candidates as $path) { if (! $this->isReadableWithinOpenBaseDir($path)) { continue; } $contents = @file_get_contents($path); if (! is_string($contents) || $contents === '') { continue; } if (Str::contains($contents, 'schedule:run') || Str::contains($contents, 'schedule:work')) { return [ 'found' => true, 'source' => $path, ]; } } return [ 'found' => false, 'source' => null, ]; } private function detectScheduleEntryViaShell(): array { $output = trim((string) $this->safeShell( "grep -R -n -m 1 -E 'schedule:(run|work)' /etc/crontab /etc/cron.d /var/spool/cron /var/spool/cron/crontabs 2>/dev/null" )); if ($output === '') { return [ 'found' => false, 'source' => null, ]; } $firstLine = trim(Str::before($output, PHP_EOL)); $source = trim(Str::before($firstLine, ':')); return [ 'found' => true, 'source' => $source !== '' ? $source : $firstLine, ]; } private function isReadableWithinOpenBaseDir(string $path): bool { $openBaseDir = trim((string) ini_get('open_basedir')); if ($openBaseDir === '') { return @is_readable($path); } $normalizedPath = $this->normalizePath($path); $allowedRoots = collect(explode(PATH_SEPARATOR, $openBaseDir)) ->map(fn (string $root) => $this->normalizePath($root)) ->filter(); $isAllowed = $allowedRoots->contains(fn (string $root) => Str::startsWith($normalizedPath, $root)); return $isAllowed && @is_readable($path); } private function normalizePath(string $path): string { return rtrim(str_replace('\\', '/', trim($path)), '/'); } private function safeShell(string $command): ?string { if (app()->runningUnitTests() || ! function_exists('shell_exec')) { return null; } try { $output = @shell_exec($command); } catch (\Throwable) { return null; } return is_string($output) ? $output : null; } private function isHuggingFaceSpace(): bool { $appUrl = trim((string) config('app.url', '')); $spaceHost = trim((string) ( $_ENV['SPACE_HOST'] ?? $_SERVER['SPACE_HOST'] ?? getenv('SPACE_HOST') ?? '' )); return Str::contains($appUrl, '.hf.space') || Str::endsWith($spaceHost, '.hf.space'); } private function platformState(int $completion, array $counts): string { return match (true) { $completion >= 100 => 'Live', $counts['failed'] > 0 && $completion >= 45 => 'Needs attention', $completion >= 75 => 'Ready', $completion >= 45 => 'Configuring', default => 'Setup needed', }; } private function platformStateTone(int $completion, array $counts): string { return match (true) { $completion >= 100 => 'healthy', $counts['failed'] > 0 && $completion >= 45 => 'critical', $completion >= 75 => 'ready', $completion >= 45 => 'warning', default => 'pending', }; } private function nextAction(bool $hasToken, bool $hasQueue, bool $hasSuccessfulPost, array $counts): string { if (! $hasToken) { return 'Simpan access token dulu supaya platform bisa ikut publish otomatis.'; } if ($counts['failed'] > 0) { return 'Ada post yang gagal. Cek credential, retry, atau media requirement platform ini.'; } if (! $hasQueue) { return 'Credential sudah siap. Langkah berikutnya tinggal kirim draft pertama ke queue.'; } if (! $hasSuccessfulPost) { return 'Queue sudah ada. Tinggal tunggu approval atau scheduler untuk publish pertama.'; } return 'Platform ini sudah punya jejak publish dan siap dipakai untuk campaign berikutnya.'; } }