Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.48% covered (success)
97.48%
232 / 238
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
DashboardShow
97.48% covered (success)
97.48%
232 / 238
83.33% covered (warning)
83.33%
10 / 12
61
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 showDashboard
100.00% covered (success)
100.00%
89 / 89
100.00% covered (success)
100.00%
1 / 1
9
 showSession
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
6
 deleteSession
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 mergeSessions
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
8
 ungroupSession
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 resetAll
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 watchMode
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 colorEvent
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 eventDetails
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
10
 formatSeconds
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace App\Console\Commands;
4
5use App\Models\TelemetryMetric;
6use App\Models\TelemetryEvent;
7use App\Models\TelemetrySession;
8use App\Services\DashboardQueryService;
9use App\Services\TelemetryService;
10use Illuminate\Console\Command;
11
12class 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}