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 | ||
| 21 | final 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 | } |