Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.48% |
232 / 238 |
|
83.33% |
10 / 12 |
CRAP | |
0.00% |
0 / 1 |
| DashboardShow | |
97.48% |
232 / 238 |
|
83.33% |
10 / 12 |
61 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| handle | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
7.02 | |||
| showDashboard | |
100.00% |
89 / 89 |
|
100.00% |
1 / 1 |
9 | |||
| showSession | |
100.00% |
51 / 51 |
|
100.00% |
1 / 1 |
6 | |||
| deleteSession | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| mergeSessions | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
8 | |||
| ungroupSession | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| resetAll | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| watchMode | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| colorEvent | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
7 | |||
| eventDetails | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
10 | |||
| formatSeconds | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Console\Commands; |
| 4 | |
| 5 | use App\Models\TelemetryMetric; |
| 6 | use App\Models\TelemetryEvent; |
| 7 | use App\Models\TelemetrySession; |
| 8 | use App\Services\DashboardQueryService; |
| 9 | use App\Services\TelemetryService; |
| 10 | use Illuminate\Console\Command; |
| 11 | |
| 12 | class DashboardShow extends Command |
| 13 | { |
| 14 | protected $signature = 'dashboard:show |
| 15 | {--session= : Show details for a specific session ID} |
| 16 | {--watch : Continuously refresh the dashboard} |
| 17 | {--delete= : Delete a specific session and its data} |
| 18 | {--reset : Reset all telemetry data} |
| 19 | {--merge= : Merge two sessions (format: SOURCE_ID:TARGET_ID)} |
| 20 | {--ungroup= : Remove a session from its group}'; |
| 21 | |
| 22 | protected $description = 'Display the Claude Board telemetry dashboard in the console'; |
| 23 | |
| 24 | public function __construct( |
| 25 | private readonly DashboardQueryService $query, |
| 26 | private readonly TelemetryService $telemetry, |
| 27 | ) { |
| 28 | parent::__construct(); |
| 29 | } |
| 30 | |
| 31 | public function handle(): int |
| 32 | { |
| 33 | if ($this->option('reset')) { |
| 34 | return $this->resetAll(); |
| 35 | } |
| 36 | |
| 37 | if ($mergeArg = $this->option('merge')) { |
| 38 | return $this->mergeSessions($mergeArg); |
| 39 | } |
| 40 | |
| 41 | if ($ungroupId = $this->option('ungroup')) { |
| 42 | return $this->ungroupSession($ungroupId); |
| 43 | } |
| 44 | |
| 45 | if ($deleteId = $this->option('delete')) { |
| 46 | return $this->deleteSession($deleteId); |
| 47 | } |
| 48 | |
| 49 | if ($this->option('watch')) { |
| 50 | return $this->watchMode(); |
| 51 | } |
| 52 | |
| 53 | if ($sessionId = $this->option('session')) { |
| 54 | return $this->showSession($sessionId); |
| 55 | } |
| 56 | |
| 57 | return $this->showDashboard(); |
| 58 | } |
| 59 | |
| 60 | private function showDashboard(): int |
| 61 | { |
| 62 | $summary = $this->query->getSummary(); |
| 63 | $tokens = $this->query->getTokenBreakdown(); |
| 64 | $loc = $this->query->getLinesOfCodeBreakdown(); |
| 65 | $costByModel = $this->query->getCostByModel(); |
| 66 | $toolUsage = $this->query->getToolUsage(); |
| 67 | $apiPerf = $this->query->getApiPerformance(); |
| 68 | $events = $this->query->getRecentEvents(10); |
| 69 | $isApi = config('claude-board.billing_model') === 'api'; |
| 70 | |
| 71 | $this->newLine(); |
| 72 | $this->line('<fg=cyan;options=bold> ╔══════════════════════════════════════════╗</>'); |
| 73 | $this->line('<fg=cyan;options=bold> ║ '.__('dashboard.cli_title').' ║</>'); |
| 74 | $this->line('<fg=cyan;options=bold> ╚══════════════════════════════════════════╝</>'); |
| 75 | $this->newLine(); |
| 76 | |
| 77 | $this->table( |
| 78 | [__('dashboard.metric'), __('dashboard.value')], |
| 79 | [ |
| 80 | [__('dashboard.cli_sessions_total'), $summary['total_sessions']], |
| 81 | [__('dashboard.cli_sessions_active'), '<fg=green>'.$summary['active_sessions'].'</>'], |
| 82 | [__('dashboard.cost_field_'.($isApi ? 'api' : 'subscription')), '<fg=yellow>$'.number_format($summary['total_cost'], 4).'</>'], |
| 83 | [__('dashboard.cli_total_tokens'), number_format($summary['total_tokens'])], |
| 84 | [__('dashboard.cli_active_time'), $this->formatSeconds($summary['total_active_time'])], |
| 85 | [__('dashboard.cli_lines_added'), '<fg=green>+'.number_format($loc['added']).'</>'], |
| 86 | [__('dashboard.cli_lines_removed'), '<fg=red>-'.number_format($loc['removed']).'</>'], |
| 87 | [__('dashboard.commits'), $summary['total_commits']], |
| 88 | [__('dashboard.cli_pull_requests'), $summary['total_prs']], |
| 89 | ] |
| 90 | ); |
| 91 | |
| 92 | if (array_sum($tokens) > 0) { |
| 93 | $this->newLine(); |
| 94 | $this->info(' '.__('dashboard.cli_token_breakdown')); |
| 95 | $this->table( |
| 96 | [__('dashboard.cli_type'), __('dashboard.cli_count')], |
| 97 | [ |
| 98 | [__('dashboard.input'), number_format($tokens['input'])], |
| 99 | [__('dashboard.output'), number_format($tokens['output'])], |
| 100 | [__('dashboard.cache_read'), number_format($tokens['cache_read'])], |
| 101 | [__('dashboard.cache_creation'), number_format($tokens['cache_creation'])], |
| 102 | ] |
| 103 | ); |
| 104 | } |
| 105 | |
| 106 | if ($costByModel->isNotEmpty()) { |
| 107 | $this->newLine(); |
| 108 | $this->info(' '.__('dashboard.cli_cost_table_'.($isApi ? 'api' : 'subscription'))); |
| 109 | $this->table( |
| 110 | [__('dashboard.model'), __('dashboard.cli_cost_col_'.($isApi ? 'api' : 'subscription')), __('dashboard.reqs')], |
| 111 | $costByModel->map(fn ($row) => [ |
| 112 | $row->model ?? 'unknown', |
| 113 | '$'.number_format((float) $row->total_cost, 4), |
| 114 | number_format((int) $row->request_count), |
| 115 | ])->toArray() |
| 116 | ); |
| 117 | } |
| 118 | |
| 119 | if ($toolUsage->isNotEmpty()) { |
| 120 | $this->newLine(); |
| 121 | $this->info(' '.__('dashboard.cli_tool_usage')); |
| 122 | $this->table( |
| 123 | [__('dashboard.tool'), __('dashboard.cli_invocations'), __('dashboard.cli_success_rate'), __('dashboard.cli_avg_duration')], |
| 124 | $toolUsage->map(fn ($row) => [ |
| 125 | $row->tool_name ?? 'unknown', |
| 126 | number_format((int) $row->invocations), |
| 127 | $row->invocations > 0 |
| 128 | ? round(($row->successes / $row->invocations) * 100, 1).'%' |
| 129 | : 'N/A', |
| 130 | round((float) $row->avg_duration_ms).'ms', |
| 131 | ])->toArray() |
| 132 | ); |
| 133 | } |
| 134 | |
| 135 | $this->newLine(); |
| 136 | $this->info(' '.__('dashboard.cli_api_performance')); |
| 137 | $this->table( |
| 138 | [__('dashboard.metric'), __('dashboard.value')], |
| 139 | [ |
| 140 | [__('dashboard.total_requests'), number_format($apiPerf['total_requests'])], |
| 141 | [__('dashboard.cli_total_errors'), $apiPerf['total_errors']], |
| 142 | [__('dashboard.error_rate'), $apiPerf['error_rate'].'%'], |
| 143 | [__('dashboard.cli_avg_response_time'), $apiPerf['avg_duration_ms'].'ms'], |
| 144 | ] |
| 145 | ); |
| 146 | |
| 147 | if ($events->isNotEmpty()) { |
| 148 | $this->newLine(); |
| 149 | $this->info(' '.__('dashboard.cli_recent_events')); |
| 150 | $this->table( |
| 151 | [__('dashboard.time'), __('dashboard.event'), __('dashboard.session'), __('dashboard.details')], |
| 152 | $events->map(fn ($e) => [ |
| 153 | $e->recorded_at?->format('H:i:s') ?? '-', |
| 154 | $this->colorEvent($e->event_name), |
| 155 | substr($e->session_id, 0, 12).'...', |
| 156 | $this->eventDetails($e), |
| 157 | ])->toArray() |
| 158 | ); |
| 159 | } |
| 160 | |
| 161 | $this->newLine(); |
| 162 | |
| 163 | return self::SUCCESS; |
| 164 | } |
| 165 | |
| 166 | private function showSession(string $sessionId): int |
| 167 | { |
| 168 | try { |
| 169 | $data = $this->query->getSessionDetail($sessionId); |
| 170 | } catch (\Illuminate\Database\Eloquent\ModelNotFoundException) { |
| 171 | $this->error(__('dashboard.cli_session_not_found', ['id' => $sessionId])); |
| 172 | |
| 173 | return self::FAILURE; |
| 174 | } |
| 175 | |
| 176 | $session = $data['session']; |
| 177 | $isApi = ($session->billing_model ?? config('claude-board.billing_model')) === 'api'; |
| 178 | |
| 179 | $this->newLine(); |
| 180 | $this->line('<fg=cyan;options=bold> SESSION: '.$sessionId.'</>'); |
| 181 | $this->newLine(); |
| 182 | |
| 183 | $this->table( |
| 184 | [__('dashboard.cli_field'), __('dashboard.value')], |
| 185 | [ |
| 186 | [__('dashboard.project'), $session->project_name ?? '-'], |
| 187 | [__('dashboard.email'), $session->user_email ?? '-'], |
| 188 | [__('dashboard.user_id'), $session->user_id ?? '-'], |
| 189 | [__('dashboard.version'), $session->app_version ?? '-'], |
| 190 | [__('dashboard.terminal'), $session->terminal_type ?? '-'], |
| 191 | [__('dashboard.first_seen'), $session->first_seen_at?->format('Y-m-d H:i:s') ?? '-'], |
| 192 | [__('dashboard.last_seen'), $session->last_seen_at?->format('Y-m-d H:i:s') ?? '-'], |
| 193 | [__('dashboard.cost_field_'.($isApi ? 'api' : 'subscription')), '$'.number_format($data['cost'], 4)], |
| 194 | [__('dashboard.tokens'), number_format($data['tokens'])], |
| 195 | ] |
| 196 | ); |
| 197 | |
| 198 | if ($session->session_group_id) { |
| 199 | $grouped = TelemetrySession::where('session_group_id', $session->session_group_id) |
| 200 | ->where('session_id', '!=', $sessionId) |
| 201 | ->orderBy('first_seen_at') |
| 202 | ->get(); |
| 203 | |
| 204 | if ($grouped->isNotEmpty()) { |
| 205 | $this->newLine(); |
| 206 | $this->info(' '.__('dashboard.related_sessions').' ('.$grouped->count().')'); |
| 207 | $this->table( |
| 208 | [__('dashboard.session_id'), __('dashboard.first_seen'), __('dashboard.last_seen')], |
| 209 | $grouped->map(fn ($s) => [ |
| 210 | $s->session_id, |
| 211 | $s->first_seen_at?->format('Y-m-d H:i:s') ?? '-', |
| 212 | $s->last_seen_at?->format('Y-m-d H:i:s') ?? '-', |
| 213 | ])->toArray() |
| 214 | ); |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | if ($data['events']->isNotEmpty()) { |
| 219 | $this->newLine(); |
| 220 | $this->info(' '.__('dashboard.events').' ('.$data['events']->count().')'); |
| 221 | $this->table( |
| 222 | [__('dashboard.time'), __('dashboard.event'), __('dashboard.details')], |
| 223 | $data['events']->take(25)->map(fn ($e) => [ |
| 224 | $e->recorded_at?->format('H:i:s') ?? '-', |
| 225 | $this->colorEvent($e->event_name), |
| 226 | $this->eventDetails($e), |
| 227 | ])->toArray() |
| 228 | ); |
| 229 | } |
| 230 | |
| 231 | return self::SUCCESS; |
| 232 | } |
| 233 | |
| 234 | private function deleteSession(string $sessionId): int |
| 235 | { |
| 236 | $session = TelemetrySession::where('session_id', $sessionId)->first(); |
| 237 | |
| 238 | if (! $session) { |
| 239 | $this->error(__('dashboard.cli_session_not_found', ['id' => $sessionId])); |
| 240 | |
| 241 | return self::FAILURE; |
| 242 | } |
| 243 | |
| 244 | if (! $this->confirm(__('dashboard.cli_delete_confirm', ['id' => $sessionId]))) { |
| 245 | $this->info(__('dashboard.cli_aborted')); |
| 246 | |
| 247 | return self::SUCCESS; |
| 248 | } |
| 249 | |
| 250 | $this->telemetry->deleteSession($sessionId); |
| 251 | $this->info(__('dashboard.cli_session_deleted', ['id' => $sessionId])); |
| 252 | |
| 253 | return self::SUCCESS; |
| 254 | } |
| 255 | |
| 256 | private function mergeSessions(string $arg): int |
| 257 | { |
| 258 | $parts = explode(':', $arg); |
| 259 | if (count($parts) !== 2 || empty($parts[0]) || empty($parts[1])) { |
| 260 | $this->error(__('dashboard.cli_merge_format')); |
| 261 | |
| 262 | return self::FAILURE; |
| 263 | } |
| 264 | |
| 265 | [$sourceId, $targetId] = $parts; |
| 266 | |
| 267 | if ($sourceId === $targetId) { |
| 268 | $this->error(__('dashboard.cli_merge_same')); |
| 269 | |
| 270 | return self::FAILURE; |
| 271 | } |
| 272 | |
| 273 | $source = TelemetrySession::where('session_id', $sourceId)->first(); |
| 274 | $target = TelemetrySession::where('session_id', $targetId)->first(); |
| 275 | |
| 276 | if (! $source) { |
| 277 | $this->error(__('dashboard.cli_source_not_found', ['id' => $sourceId])); |
| 278 | |
| 279 | return self::FAILURE; |
| 280 | } |
| 281 | if (! $target) { |
| 282 | $this->error(__('dashboard.cli_target_not_found', ['id' => $targetId])); |
| 283 | |
| 284 | return self::FAILURE; |
| 285 | } |
| 286 | |
| 287 | $metricCount = TelemetryMetric::where('session_id', $sourceId)->count(); |
| 288 | $eventCount = TelemetryEvent::where('session_id', $sourceId)->count(); |
| 289 | |
| 290 | if (! $this->confirm(__('dashboard.cli_merge_confirm', ['source' => $sourceId, 'target' => $targetId, 'metrics' => $metricCount, 'events' => $eventCount]))) { |
| 291 | $this->info(__('dashboard.cli_aborted')); |
| 292 | |
| 293 | return self::SUCCESS; |
| 294 | } |
| 295 | |
| 296 | $this->telemetry->mergeSessions($sourceId, $targetId); |
| 297 | $this->info(__('dashboard.cli_session_merged', ['source' => $sourceId, 'target' => $targetId])); |
| 298 | |
| 299 | return self::SUCCESS; |
| 300 | } |
| 301 | |
| 302 | private function ungroupSession(string $sessionId): int |
| 303 | { |
| 304 | try { |
| 305 | $this->telemetry->ungroupSession($sessionId); |
| 306 | } catch (\Illuminate\Database\Eloquent\ModelNotFoundException) { |
| 307 | $this->error(__('dashboard.cli_session_not_found', ['id' => $sessionId])); |
| 308 | |
| 309 | return self::FAILURE; |
| 310 | } |
| 311 | |
| 312 | $this->info(__('dashboard.cli_session_ungrouped', ['id' => $sessionId])); |
| 313 | |
| 314 | return self::SUCCESS; |
| 315 | } |
| 316 | |
| 317 | private function resetAll(): int |
| 318 | { |
| 319 | $count = TelemetrySession::count(); |
| 320 | |
| 321 | if ($count === 0) { |
| 322 | $this->info(__('dashboard.cli_no_data')); |
| 323 | |
| 324 | return self::SUCCESS; |
| 325 | } |
| 326 | |
| 327 | if (! $this->confirm(__('dashboard.cli_reset_confirm', ['count' => $count]))) { |
| 328 | $this->info(__('dashboard.cli_aborted')); |
| 329 | |
| 330 | return self::SUCCESS; |
| 331 | } |
| 332 | |
| 333 | $this->telemetry->resetAll(); |
| 334 | $this->info(__('dashboard.cli_reset_done')); |
| 335 | |
| 336 | return self::SUCCESS; |
| 337 | } |
| 338 | |
| 339 | private function watchMode(): int |
| 340 | { |
| 341 | $this->info(__('dashboard.cli_watch_mode')); |
| 342 | |
| 343 | while (true) { |
| 344 | system('clear'); |
| 345 | $this->showDashboard(); |
| 346 | sleep(5); |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | private function colorEvent(string $eventName): string |
| 351 | { |
| 352 | $base = str_replace('claude_code.', '', $eventName); |
| 353 | |
| 354 | return match ($base) { |
| 355 | 'api_request' => '<fg=blue>'.$base.'</>', |
| 356 | 'api_error' => '<fg=red>'.$base.'</>', |
| 357 | 'tool_result' => '<fg=green>'.$base.'</>', |
| 358 | 'user_prompt' => '<fg=yellow>'.$base.'</>', |
| 359 | 'tool_decision' => '<fg=magenta>'.$base.'</>', |
| 360 | default => $base, |
| 361 | }; |
| 362 | } |
| 363 | |
| 364 | private function eventDetails(TelemetryEvent $event): string |
| 365 | { |
| 366 | $attrs = $event->attributes ?? []; |
| 367 | $parts = []; |
| 368 | |
| 369 | if (isset($attrs['tool_name'])) { |
| 370 | $parts[] = $attrs['tool_name']; |
| 371 | } |
| 372 | if (isset($attrs['model'])) { |
| 373 | $parts[] = $attrs['model']; |
| 374 | } |
| 375 | if (isset($attrs['cost_usd'])) { |
| 376 | $parts[] = '$'.$attrs['cost_usd']; |
| 377 | } |
| 378 | if (isset($attrs['duration_ms'])) { |
| 379 | $parts[] = $attrs['duration_ms'].'ms'; |
| 380 | } |
| 381 | if (isset($attrs['success'])) { |
| 382 | $parts[] = $attrs['success'] === 'true' || $attrs['success'] === true ? 'OK' : 'FAIL'; |
| 383 | } |
| 384 | if (isset($attrs['error'])) { |
| 385 | $parts[] = substr($attrs['error'], 0, 40); |
| 386 | } |
| 387 | |
| 388 | return implode(' | ', $parts) ?: '-'; |
| 389 | } |
| 390 | |
| 391 | private function formatSeconds(int $seconds): string |
| 392 | { |
| 393 | if ($seconds < 60) { |
| 394 | return $seconds.'s'; |
| 395 | } |
| 396 | if ($seconds < 3600) { |
| 397 | return round($seconds / 60, 1).'min'; |
| 398 | } |
| 399 | |
| 400 | return round($seconds / 3600, 1).'h'; |
| 401 | } |
| 402 | } |