Lines 92.66% 139 / 150
Methods 60.00% 9 / 15
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 render 100.00% 5 / 5 100.00% 1 / 1 1
 renderDaemonTable 100.00% 19 / 19 100.00% 1 / 1 1
 renderWorkerTable 100.00% 21 / 21 100.00% 1 / 1 7
 renderAppTable 100.00% 22 / 22 100.00% 1 / 1 2
 formatAppState 83.33% 5 / 6 0.00% 0 / 1 4.07
 renderFailureTable 100.00% 6 / 6 100.00% 1 / 1 3
 describeConnection 100.00% 7 / 7 100.00% 1 / 1 3
 describeReconnects 66.66% 2 / 3 0.00% 0 / 1 2.15
 describeComponent 90.90% 10 / 11 0.00% 0 / 1 8.05
 describeStoreHealth 62.50% 5 / 8 0.00% 0 / 1 6.32
 describeDeploy 88.23% 15 / 17 0.00% 0 / 1 3.01
 formatCalls 100.00% 7 / 7 100.00% 1 / 1 3
 formatLatency 100.00% 6 / 6 100.00% 1 / 1 5
 renderTable 72.72% 8 / 11 0.00% 0 / 1 2.08
21final readonly class StatusRenderer
22{
23    private const float LATENCY_QUANTILE = 0.95;
24
25    private const int SHORT_COMMIT_LENGTH = 7;
26
27    public function __construct(private StatusFormatter $formatter) {}
28
29    public function render(OutputInterface $output, RuntimeSnapshot $snapshot): void
30    {
31        // The daemon's clock, so skew between a remote daemon and this CLI does not distort ages.
32        $now = $snapshot->takenAt;
33
34        $this->renderDaemonTable($output, $snapshot, $now);
35        $this->renderWorkerTable($output, $snapshot, $now);
36        $this->renderAppTable($output, $snapshot, $now);
37        $this->renderFailureTable($output, $snapshot, $now);
38    }
39
40    private function renderDaemonTable(OutputInterface $output, RuntimeSnapshot $snapshot, Instant $now): void
41    {
42        $daemon = $snapshot->daemon;
43        $connection = $snapshot->connection;
44        $broker = $snapshot->broker;
45
46        $this->renderTable($output, 'Daemon', ['', ''], [
47            ['version', $daemon->version],
48            ['pid', (string) $daemon->pid],
49            ['up', $this->formatter->formatDuration($now->elapsedSince($daemon->startedAt))],
50            ['memory', $this->formatter->formatBytes($daemon->memoryBytes)],
51            ['home assistant', $this->describeConnection($snapshot, $now)],
52            ['reconnects', $this->describeReconnects($connection)],
53            ['component', $this->describeComponent($snapshot->component, $now)],
54            ['store', $this->describeStoreHealth($snapshot->store, $now)],
55            ['deploy', $this->describeDeploy($snapshot->deploy, $now)],
56            ['entities', (string) $daemon->entities],
57            ['time zone', $daemon->timeZone],
58            ['workers', \sprintf('%d live of %d', $broker->liveWorkers, $broker->workers)],
59            ['service calls', \sprintf('%d in flight, %d refused', $broker->inFlightServiceCalls, $broker->refusedServiceCalls)],
60            ['routing', \sprintf('%d subscriptions, %d patterns, cache %d hits / %d misses', $broker->routing->subscriptions, $broker->routing->patterns, $broker->routing->cacheHits, $broker->routing->cacheMisses)],
61        ]);
62    }
63
64    private function renderWorkerTable(OutputInterface $output, RuntimeSnapshot $snapshot, Instant $now): void
65    {
66        $this->renderTable(
67            $output,
68            'Workers',
69            ['worker', 'phase', 'pid', 'ready', 'apps', 'lag', 'memory', 'pong', 'missed', 'restarts', 'queued', 'dropped', 'batches', 'calls'],
70            array_map(fn(WorkerStatus $w): array => [
71                (string) $w->workerId,
72                $w->phase->value . ($w->restartDueAt === null ? '' : ' ' . $this->formatter->formatTimeUntil($w->restartDueAt, $now)),
73                $w->pid === null ? '-' : (string) $w->pid,
74                $this->formatter->formatYesNo($w->ready),
75                implode(', ', $w->appIds),
76                $this->formatter->formatElapsed($w->loopLag),
77                $this->formatter->formatBytes($w->memoryBytes),
78                $this->formatter->formatTimeAgo($w->lastPongAt, $now),
79                (string) $w->missedProbes,
80                $w->restarts === 0 ? '0' : \sprintf('%d (%d since start)', $w->restartsInWindow, $w->restarts),
81                $w->outbox === null ? '-' : (string) $w->outbox->queued,
82                $w->outbox === null ? '-' : (string) $w->outbox->dropped,
83                $w->outbox === null ? '-' : \sprintf('%d (%d coalesced)', $w->outbox->stateBatchesSent, $w->outbox->coalescedStateChanges),
84                (string) $w->inFlightServiceCalls,
85            ], $snapshot->workers),
86        );
87    }
88
89    private function renderAppTable(OutputInterface $output, RuntimeSnapshot $snapshot, Instant $now): void
90    {
91        $this->renderTable(
92            $output,
93            'Apps',
94            ['app', 'worker', 'state', 'reported', 'subs', 'schedules', 'exposed', 'delivered', 'dropped', 'suppressed', 'runs', 'publishes', 'calls ok/failed', 'p95', 'failures'],
95            array_map(fn(AppStatus $app): array => [
96                $app->id,
97                $app->workerId === null ? '-' : (string) $app->workerId,
98                $this->formatAppState($app, $now),
99                $this->formatter->formatTimeAgo($app->reportedAt, $now),
100                (string) $app->subscriptions,
101                (string) $app->schedules,
102                (string) $app->exposedEntities,
103                (string) $app->counters->delivered,
104                (string) $app->counters->subscriptionDropped,
105                (string) $app->counters->suppressed,
106                (string) $app->counters->scheduleRuns,
107                (string) $app->counters->publishes,
108                $this->formatCalls($app),
109                $this->formatLatency($app),
110                (string) $app->counters->failures,
111            ], $snapshot->apps),
112        );
113    }
114
115    private function formatAppState(AppStatus $app, Instant $now): string
116    {
117        $state = $app->state === null ? 'unknown' : $app->state->value;
118
119        if ($app->pause !== null) {
120            return \sprintf('%s (paused by %s %s)', $state, $app->pause->source->value, $this->formatter->formatTimeAgo($app->pause->since, $now));
121        }
122
123        if ($app->configPauseOverride !== null) {
124            return \sprintf('%s (resumed by %s %s over config)', $state, $app->configPauseOverride->source->value, $this->formatter->formatTimeAgo($app->configPauseOverride->since, $now));
125        }
126
127        return $state;
128    }
129
130    private function renderFailureTable(OutputInterface $output, RuntimeSnapshot $snapshot, Instant $now): void
131    {
132        $rows = [];
133
134        foreach ($snapshot->apps as $app) {
135            $failure = $app->lastFailure;
136
137            if ($failure !== null) {
138                $rows[] = [$app->id, $this->formatter->formatTimeAgo($failure->at, $now), $failure->phase->value, $failure->reason ?? '-', $failure->message];
139            }
140        }
141
142        $this->renderTable($output, 'Failures', ['app', 'when', 'phase', 'reason', 'message'], $rows);
143    }
144
145    private function describeConnection(RuntimeSnapshot $snapshot, Instant $now): string
146    {
147        $connection = $snapshot->connection;
148        $description = $connection->phase->value;
149
150        if ($connection->since !== null) {
151            $description .= ' since ' . $this->formatter->formatTimeAgo($connection->since, $now);
152        }
153
154        if ($snapshot->daemon->haVersion !== null) {
155            $description .= ', version ' . $snapshot->daemon->haVersion;
156        }
157
158        return $description;
159    }
160
161    private function describeReconnects(ConnectionState $connection): string
162    {
163        if ($connection->lastOutage === null) {
164            return (string) $connection->reconnects;
165        }
166
167        return \sprintf('%d, last outage %s', $connection->reconnects, $this->formatter->formatDuration($connection->lastOutage));
168    }
169
170    private function describeComponent(?ComponentStatus $component, Instant $now): string
171    {
172        if ($component === null) {
173            return 'unknown';
174        }
175
176        $description = \sprintf('%s since %s', str_replace('_', ' ', $component->state->value), $this->formatter->formatTimeAgo($component->since, $now));
177
178        if ($component->version !== null) {
179            $description .= ', version ' . $component->version;
180        }
181
182        return match ($component->state) {
183            ComponentState::Missing => $description . ', install the stewart integration in Home Assistant',
184            ComponentState::ProtocolMismatch => \sprintf('%s, protocol %d where Stewart speaks %d', $description, $component->protocol ?? 0, ComponentProtocol::VERSION),
185            ComponentState::Refused => $description . ', see the daemon log',
186            ComponentState::Replaced => \sprintf('%s, another Stewart uses expose.instance "%s"', $description, $component->instance),
187            ComponentState::Unchecked, ComponentState::Active => $description,
188        };
189    }
190
191    private function describeStoreHealth(?StoreHealth $health, Instant $now): string
192    {
193        if ($health === null) {
194            return 'not configured';
195        }
196
197        $state = $health->available ? 'up' : 'down';
198
199        if ($health->lastFailure === null) {
200            return $state;
201        }
202
203        return $health->lastFailureAt === null
204            ? \sprintf('%s, last failure: %s', $state, $health->lastFailure)
205            : \sprintf('%s, last failure %s: %s', $state, $this->formatter->formatTimeAgo($health->lastFailureAt, $now), $health->lastFailure);
206    }
207
208    private function describeDeploy(?DeployStatus $deploy, Instant $now): string
209    {
210        if ($deploy === null) {
211            return 'not polling';
212        }
213
214        $description = \sprintf(
215            '%s, polled %s',
216            substr($deploy->commit, 0, self::SHORT_COMMIT_LENGTH),
217            $this->formatter->formatTimeAgo($deploy->lastPolledAt, $now),
218        );
219
220        if ($deploy->lastFailure === null) {
221            return $description;
222        }
223
224        return \sprintf(
225            '%s, %d rejected, last %s %s: %s',
226            $description,
227            $deploy->failures,
228            substr($deploy->lastFailure->commit, 0, self::SHORT_COMMIT_LENGTH),
229            $this->formatter->formatTimeAgo($deploy->lastFailure->failedAt, $now),
230            $deploy->lastFailure->reason,
231        );
232    }
233
234    private function formatCalls(AppStatus $app): string
235    {
236        $succeeded = 0;
237        $failed = 0;
238
239        foreach ($app->serviceCalls as $stats) {
240            if ($stats->outcome->isFailure()) {
241                $failed += $stats->count;
242            } else {
243                $succeeded += $stats->count;
244            }
245        }
246
247        return \sprintf('%d/%d', $succeeded, $failed);
248    }
249
250    private function formatLatency(AppStatus $app): string
251    {
252        foreach ($app->serviceCalls as $stats) {
253            if ($stats->outcome !== ServiceCallOutcome::Succeeded || $stats->latency === null) {
254                continue;
255            }
256
257            $bound = $stats->latency->quantileBoundMs(self::LATENCY_QUANTILE);
258            $last = max(0, ...$stats->latency->boundsMs);
259
260            return $bound === null ? \sprintf('> %d ms', $last) : \sprintf('≤ %d ms', $bound);
261        }
262
263        return '-';
264    }
265
266    /**
267     * @param list<string> $headers
268     * @param list<list<string>> $rows
269     */
270    private function renderTable(OutputInterface $output, string $title, array $headers, array $rows): void
271    {
272        $output->writeln('<info>' . $title . '</info>');
273
274        if ($rows === []) {
275            $output->writeln('  (none)');
276            $output->writeln('');
277
278            return;
279        }
280
281        new Table($output)
282            ->setStyle('compact')
283            ->setHeaders($headers)
284            ->setRows($rows)
285            ->render();
286        $output->writeln('');
287    }
288}