(string) config('projectsend.version'), 'edition' => $capabilities->edition()->value, 'capabilities' => $capabilities->enabledKeys(), 'seats' => [ 'staff' => [ 'used' => $seats->staffUsed(), // null is unlimited, and is emitted as null rather than // as 0 or as an absent key: a reader that mistook one // for the other would report an installation selling // unlimited accounts as one that may hold none. 'limit' => $seats->staffLimit(), ], 'clients' => [ 'used' => $seats->clientUsed(), 'limit' => $seats->clientLimit(), ], ], 'activity' => [ // Null means "no staff account has ever signed in here", // and is emitted rather than left out for the same reason // an unlimited seat count is: a watcher has to be able to // tell that apart from "we got no answer". Collapsing the // two is how a broken probe reads as a dormant fleet. 'last_staff_login_at' => $this->lastStaffLoginAt(), ], 'storage' => $this->storage(), 'health' => $this->health(), 'settings' => [ // Echoed back rather than assumed: an operator writes the // environment variable, and this is the installation // saying what it actually applied. Read the way // EnforceTwoFactor reads it, down to what an unreadable // value falls back to -- reporting a stricter answer than // the middleware enforces would be worse than reporting // none at all. 'two_factor_enforcement' => $this->enforcement($settings), ], // Cast so an installation with no packages emits {} rather // than [] -- an empty PHP array encodes as a list, and a // reader unmarshalling a map breaks on the day it happens to // be empty rather than on the day it is written. 'modules' => (object) $this->modules(), 'build' => [ // `channel` is 'release' or 'dev'. An internal build names // itself after its commit and can never be published, so a // fleet reading 'dev' is looking at something deliberate // rather than at a mistake. 'commit' => $this->buildFact('commit'), 'ref' => $this->buildFact('ref'), 'channel' => $this->buildFact('channel'), 'built_at' => $this->buildFact('built_at'), ], ]; if ($this->option('json')) { $this->line((string) json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); return self::SUCCESS; } $this->line("ProjectSend {$status['version']} ({$status['edition']})"); $this->line('Capabilities: '.(implode(', ', $status['capabilities']) ?: 'none')); $this->line('Staff seats: '.$this->seatLine($status['seats']['staff'])); $this->line('Clients: '.$this->seatLine($status['seats']['clients'])); $this->line('Last staff login: '.($status['activity']['last_staff_login_at'] ?? 'never')); $this->line('Storage: '.number_format($status['storage']['bytes']).' bytes in '.$status['storage']['files'].' files'); $this->line('Build: '.($status['build']['ref'] ?? 'not a build') .($status['build']['channel'] === 'dev' ? ' (dev)' : '')); $this->line('Health: '.$status['health']['pending_migrations'].' migrations pending, ' .$status['health']['failed_jobs'].' failed jobs, ' .array_sum(array_filter($status['health']['queues'], 'is_int')).' queued'); return self::SUCCESS; } private function buildFact(string $key): ?string { $value = config("build.$key"); return is_string($value) && $value !== '' ? $value : null; } private function enforcement(Settings $settings): string { $value = $settings->get(Setting::TwoFactorEnforcement); $enforcement = (is_string($value) ? TwoFactorEnforcement::tryFrom($value) : null) ?? TwoFactorEnforcement::None; return $enforcement->value; } /** * What this installation holds, from the rows that record it. * * @return array{bytes: int, files: int, by_disk: object} */ private function storage(): array { $perDisk = File::query() ->groupBy('disk') ->selectRaw('disk, sum(size) as bytes, count(*) as files') ->get(); return [ 'bytes' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('bytes')), 'files' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('files')), // Keyed by disk name rather than a list, because the reader // wants one of them by name — "how much is still local" — and // not to walk a list looking for it. // Same reason as `modules`: an installation holding no files // at all must still answer with a map. 'by_disk' => (object) $perDisk ->mapWithKeys(fn (File $row): array => [ (string) $row->getAttribute('disk') => [ 'bytes' => (int) $row->getAttribute('bytes'), 'files' => (int) $row->getAttribute('files'), ], ])->all(), ]; } /** * @return array{pending_migrations: int, failed_jobs: int, queues: array} */ private function health(): array { return [ 'pending_migrations' => $this->pendingMigrations(), 'failed_jobs' => $this->failedJobs(), // The two this application actually runs workers for. A depth // is not a fault on its own -- a busy installation has one -- // but a depth that only ever grows is a worker that died, and // nothing outside the container can see the difference. 'queues' => [ 'default' => $this->queueDepth('default'), 'zips' => $this->queueDepth('zips'), ], ]; } private function pendingMigrations(): int { /** @var Migrator $migrator */ $migrator = app('migrator'); // Every path, not just database/migrations: a package registers // its own, and a package migration left unrun is exactly the kind // of half-deploy this is here to report. $files = $migrator->getMigrationFiles(array_merge([database_path('migrations')], $migrator->paths())); return count(array_diff(array_keys($files), $migrator->getRepository()->getRan())); } private function failedJobs(): int { $table = config('queue.failed.table'); if (! is_string($table) || $table === '') { return 0; } return DB::table($table)->count(); } /** * Null rather than a crash when the queue cannot be reached, and null * rather than zero: an unreachable Redis is not an empty queue, and a * reader watching for a worker that died would read the second as * everything being fine. * * This command is a probe, and a probe that dies on one unreachable * dependency tells the reader nothing about the facts it could still * have answered. */ private function queueDepth(string $queue): ?int { try { return Queue::size($queue); } catch (Throwable) { return null; } } /** * @return array */ private function modules(): array { $event = new ResolvingInstallationStatus; Event::dispatch($event); return $event->facts; } /** * The most recent interactive staff sign-in, or null if there has * never been one. */ private function lastStaffLoginAt(): ?string { $latest = ActivityLog::query() ->where('action', Action::Login->value) ->where('actor_type', UserType::Staff->value) ->max('created_at'); // `action` and `actor_type` carry an index each, so this narrows // on one of them rather than reading the log. return $latest === null ? null : Carbon::parse($latest)->toIso8601String(); } /** * @param array{used: int, limit: int|null} $seat */ private function seatLine(array $seat): string { return $seat['limit'] === null ? "{$seat['used']} of unlimited" : "{$seat['used']} of {$seat['limit']}"; } }