@pikoloo/codex-proxy 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/js/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  document.addEventListener('alpine:init', () => {
2
- const validTabs = ['dashboard', 'accounts', 'logs', 'settings'];
2
+ const validTabs = ['dashboard', 'metrics', 'accounts', 'logs', 'settings'];
3
3
  const initialTab = () => {
4
4
  const params = new URLSearchParams(window.location.search);
5
5
  const requested = params.get('tab') || window.location.hash.replace(/^#/, '');
@@ -7,10 +7,9 @@ document.addEventListener('alpine:init', () => {
7
7
  };
8
8
 
9
9
  Alpine.data('app', () => ({
10
- version: '1.0.5',
10
+ version: '1.0.7',
11
11
  connectionStatus: 'connecting',
12
12
  activeTab: initialTab(),
13
- sidebarOpen: window.innerWidth >= 1024,
14
13
  loading: false,
15
14
  toast: null,
16
15
  currentTime: '',
@@ -18,12 +17,41 @@ document.addEventListener('alpine:init', () => {
18
17
  accounts: [],
19
18
  searchQuery: '',
20
19
  stats: { total: 0, active: 0, expired: 0, planType: '-' },
20
+ metricsRange: '24h',
21
+ metricsStatusFilter: '',
22
+ metricsLoading: false,
23
+ metricsError: '',
24
+ metricsSummary: {
25
+ totals: {
26
+ requestCount: 0,
27
+ successCount: 0,
28
+ errorCount: 0,
29
+ inputTokens: 0,
30
+ outputTokens: 0,
31
+ cacheReadInputTokens: 0,
32
+ totalTokens: 0,
33
+ averageDurationMs: 0
34
+ },
35
+ byModel: [],
36
+ byAccount: [],
37
+ timeline: []
38
+ },
39
+ metricsRecent: [],
40
+ metricsStorage: null,
21
41
 
22
42
  haikuKiloModel: 'minimax/minimax-m2.5:free',
23
- accountStrategy: 'sticky',
24
- multiAccountRotationEnabled: false,
43
+ modelMappings: { opus: 'gpt-5.5', sonnet: 'gpt-5.5', haiku: 'gpt-5.4-mini' },
44
+ modelMappingDefaults: { opus: 'gpt-5.5', sonnet: 'gpt-5.5', haiku: 'gpt-5.4-mini' },
45
+ reasoningMappings: { opus: 'high', sonnet: 'medium', haiku: 'low' },
46
+ reasoningMappingDefaults: { opus: 'high', sonnet: 'medium', haiku: 'low' },
47
+ openAiModelOptions: [],
48
+ reasoningLevelOptions: [],
49
+ modelMappingSaving: null,
50
+ reasoningMappingSaving: null,
25
51
  haikuModelSaving: false,
26
- strategySaving: false,
52
+ configureClaudeOnStartup: false,
53
+ claudeProxyConfiguring: false,
54
+ claudeProxyStartupSaving: false,
27
55
  kiloEnabled: false,
28
56
  kiloModels: [],
29
57
  kiloModelsLoading: false,
@@ -41,43 +69,83 @@ document.addEventListener('alpine:init', () => {
41
69
 
42
70
  testPrompt: 'Say hello',
43
71
  testResponse: '',
72
+ testStatus: 'idle',
73
+ testError: '',
74
+ testMeta: null,
44
75
  testing: false,
45
76
 
46
77
  haikuTestPrompt: 'Say hello',
47
78
  haikuTestResponse: '',
79
+ haikuTestStatus: 'idle',
80
+ haikuTestError: '',
81
+ haikuTestMeta: null,
48
82
  haikuTesting: false,
49
83
 
50
84
  haikuModelLabel() {
51
- if (!this.kiloEnabled) return 'gpt-5.4-mini';
52
- const model = this.kiloModels.find(m => m.id === this.haikuKiloModel);
53
- return model ? model.name : this.haikuKiloModel;
85
+ return this.modelOptionName(this.modelMappings?.haiku || 'gpt-5.4-mini');
86
+ },
87
+
88
+ get testStatusText() {
89
+ const labels = {
90
+ idle: 'Ready',
91
+ running: 'Sending request',
92
+ success: 'Response received',
93
+ error: 'Request failed'
94
+ };
95
+ return labels[this.testStatus] || 'Ready';
96
+ },
97
+
98
+ get haikuTestStatusText() {
99
+ const labels = {
100
+ idle: 'Ready',
101
+ running: 'Sending Haiku request',
102
+ success: 'Response received',
103
+ error: 'Request failed'
104
+ };
105
+ return labels[this.haikuTestStatus] || 'Ready';
54
106
  },
55
107
 
56
108
  async testHaikuChat() {
57
109
  if (!this.haikuTestPrompt.trim()) return;
110
+ const startedAt = Date.now();
58
111
  this.haikuTesting = true;
59
112
  this.haikuTestResponse = '';
60
- const { ok, data } = await this.api('/v1/chat/completions', {
61
- method: 'POST',
62
- body: JSON.stringify({
63
- model: 'claude-haiku-4',
64
- messages: [{ role: 'user', content: this.haikuTestPrompt }]
65
- })
66
- });
67
- this.haikuTesting = false;
68
- if (ok && data.choices) {
69
- this.haikuTestResponse = data.choices[0].message.content;
70
- } else {
71
- this.haikuTestResponse = data?.error?.message || 'Request failed';
113
+ this.haikuTestStatus = 'running';
114
+ this.haikuTestError = '';
115
+ this.haikuTestMeta = null;
116
+
117
+ try {
118
+ const { ok, data, error } = await this.api('/v1/chat/completions', {
119
+ method: 'POST',
120
+ body: JSON.stringify({
121
+ model: 'claude-haiku-4',
122
+ messages: [{ role: 'user', content: this.haikuTestPrompt }]
123
+ })
124
+ });
125
+ const durationMs = Date.now() - startedAt;
126
+ this.haikuTestMeta = { durationMs, usage: data?.usage || null };
127
+
128
+ if (ok && data.choices) {
129
+ this.haikuTestResponse = data.choices[0].message.content;
130
+ this.haikuTestStatus = 'success';
131
+ } else {
132
+ this.haikuTestError = data?.error?.message || error || 'Request failed';
133
+ this.haikuTestResponse = this.haikuTestError;
134
+ this.haikuTestStatus = 'error';
135
+ }
136
+ } finally {
137
+ this.haikuTesting = false;
72
138
  }
73
139
  },
74
140
 
75
141
  configPath: '~/.codex-claude-proxy/accounts.json',
142
+ serverUrl: window.location.origin,
76
143
 
77
144
  logs: [],
78
145
  logSearchQuery: '',
79
146
  logFilters: { INFO: true, SUCCESS: true, WARN: true, ERROR: true, DEBUG: false },
80
147
  logEventSource: null,
148
+ logStreamStatus: 'connecting',
81
149
 
82
150
  get filteredLogs() {
83
151
  const query = this.logSearchQuery.trim().toLowerCase();
@@ -88,12 +156,44 @@ document.addEventListener('alpine:init', () => {
88
156
  });
89
157
  },
90
158
 
159
+ get logLevelCounts() {
160
+ return this.logs.reduce((counts, log) => {
161
+ counts[log.level] = (counts[log.level] || 0) + 1;
162
+ return counts;
163
+ }, { INFO: 0, SUCCESS: 0, WARN: 0, ERROR: 0, DEBUG: 0 });
164
+ },
165
+
91
166
  get filteredAccounts() {
92
167
  if (!this.searchQuery) return this.accounts;
93
168
  const q = this.searchQuery.toLowerCase();
94
169
  return this.accounts.filter(a => a.email.toLowerCase().includes(q));
95
170
  },
96
171
 
172
+ get metricsTotals() {
173
+ return this.metricsSummary?.totals || {
174
+ requestCount: 0,
175
+ successCount: 0,
176
+ errorCount: 0,
177
+ inputTokens: 0,
178
+ outputTokens: 0,
179
+ cacheReadInputTokens: 0,
180
+ totalTokens: 0,
181
+ averageDurationMs: 0
182
+ };
183
+ },
184
+
185
+ get metricsTimelineMax() {
186
+ return Math.max(1, ...this.metricsSummary.timeline.map((entry) => Number(entry.totalTokens) || 0));
187
+ },
188
+
189
+ get metricsModelMax() {
190
+ return Math.max(1, ...this.metricsSummary.byModel.map((entry) => Number(entry.totalTokens) || 0));
191
+ },
192
+
193
+ get metricsAccountMax() {
194
+ return Math.max(1, ...this.metricsSummary.byAccount.map((entry) => Number(entry.totalTokens) || 0));
195
+ },
196
+
97
197
  init() {
98
198
  this.updateTime();
99
199
  setInterval(() => this.updateTime(), 1000);
@@ -101,12 +201,10 @@ document.addEventListener('alpine:init', () => {
101
201
  this.checkHealth();
102
202
  setInterval(() => this.checkHealth(), 30000);
103
203
  this.startLogStream();
204
+ this.loadModelMappingsSetting();
104
205
  this.loadHaikuModelSetting();
105
- this.loadAccountStrategySetting();
106
-
107
- window.addEventListener('resize', () => {
108
- this.sidebarOpen = window.innerWidth >= 1024;
109
- });
206
+ this.loadClaudeProxySetting();
207
+ this.loadMetrics();
110
208
 
111
209
  window.addEventListener('message', (event) => {
112
210
  if (event.data && event.data.type === 'oauth-success') {
@@ -124,6 +222,9 @@ document.addEventListener('alpine:init', () => {
124
222
  setActiveTab(tab) {
125
223
  if (!validTabs.includes(tab)) return;
126
224
  this.activeTab = tab;
225
+ if (tab === 'metrics') {
226
+ this.loadMetrics();
227
+ }
127
228
  const nextUrl = new URL(window.location.href);
128
229
  if (tab === 'dashboard') {
129
230
  nextUrl.searchParams.delete('tab');
@@ -133,9 +234,6 @@ document.addEventListener('alpine:init', () => {
133
234
  nextUrl.hash = '';
134
235
  }
135
236
  window.history.replaceState({}, '', nextUrl);
136
- if (window.innerWidth < 1024) {
137
- this.sidebarOpen = false;
138
- }
139
237
  },
140
238
 
141
239
  async api(endpoint, options = {}) {
@@ -174,6 +272,105 @@ document.addEventListener('alpine:init', () => {
174
272
  this.loading = false;
175
273
  },
176
274
 
275
+ refreshCurrentView() {
276
+ if (this.activeTab === 'metrics') {
277
+ return this.loadMetrics();
278
+ }
279
+ return this.refreshAccounts();
280
+ },
281
+
282
+ async loadMetrics() {
283
+ this.metricsLoading = true;
284
+ this.metricsError = '';
285
+ const params = new URLSearchParams({ range: this.metricsRange });
286
+ if (this.metricsStatusFilter) {
287
+ params.set('status', this.metricsStatusFilter);
288
+ }
289
+
290
+ const [summary, recent, storage] = await Promise.all([
291
+ this.api(`/api/metrics/summary?${params.toString()}`),
292
+ this.api(`/api/metrics/recent?${params.toString()}&limit=50`),
293
+ this.api('/api/metrics/storage')
294
+ ]);
295
+
296
+ if (summary.ok && summary.data?.summary) {
297
+ this.metricsSummary = summary.data.summary;
298
+ } else {
299
+ this.metricsError = summary.data?.error || summary.error || 'Failed to load metrics';
300
+ }
301
+
302
+ if (recent.ok && Array.isArray(recent.data?.events)) {
303
+ this.metricsRecent = recent.data.events;
304
+ }
305
+
306
+ if (storage.ok && storage.data?.storage) {
307
+ this.metricsStorage = storage.data.storage;
308
+ }
309
+
310
+ this.metricsLoading = false;
311
+ },
312
+
313
+ setMetricsRange(range) {
314
+ if (this.metricsRange === range) return;
315
+ this.metricsRange = range;
316
+ this.loadMetrics();
317
+ },
318
+
319
+ setMetricsStatusFilter(status) {
320
+ if (this.metricsStatusFilter === status) return;
321
+ this.metricsStatusFilter = status;
322
+ this.loadMetrics();
323
+ },
324
+
325
+ metricBarWidth(value, maxValue) {
326
+ const valueNumber = Number(value) || 0;
327
+ const maxNumber = Number(maxValue) || 1;
328
+ return Math.max(2, Math.min(100, Math.round((valueNumber / maxNumber) * 100)));
329
+ },
330
+
331
+ formatTokenCount(value) {
332
+ const number = Number(value) || 0;
333
+ if (number >= 1000000) return `${(number / 1000000).toFixed(1)}M`;
334
+ if (number >= 1000) return `${(number / 1000).toFixed(1)}K`;
335
+ return String(number);
336
+ },
337
+
338
+ formatDuration(value) {
339
+ const number = Number(value) || 0;
340
+ if (number >= 1000) return `${(number / 1000).toFixed(1)}s`;
341
+ return `${number}ms`;
342
+ },
343
+
344
+ formatUsageSummary(usage) {
345
+ if (!usage) return '';
346
+ const input = Number(usage.prompt_tokens ?? usage.input_tokens) || 0;
347
+ const output = Number(usage.completion_tokens ?? usage.output_tokens) || 0;
348
+ const total = Number(usage.total_tokens ?? (input + output)) || 0;
349
+ if (total <= 0) return '';
350
+ return `${this.formatTokenCount(total)} tokens (${this.formatTokenCount(input)} in, ${this.formatTokenCount(output)} out)`;
351
+ },
352
+
353
+ formatBytes(value) {
354
+ const number = Number(value) || 0;
355
+ if (number >= 1024 * 1024) return `${(number / (1024 * 1024)).toFixed(1)} MB`;
356
+ if (number >= 1024) return `${(number / 1024).toFixed(1)} KB`;
357
+ return `${number} B`;
358
+ },
359
+
360
+ formatMetricTime(value) {
361
+ if (!value) return '-';
362
+ const date = new Date(value);
363
+ if (Number.isNaN(date.getTime())) return '-';
364
+ return date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
365
+ },
366
+
367
+ metricsStatusClass(status) {
368
+ const code = Number(status) || 0;
369
+ if (code >= 200 && code < 400) return 'metrics-status-success';
370
+ if (code >= 400) return 'metrics-status-error';
371
+ return 'metrics-status-muted';
372
+ },
373
+
177
374
  async refreshAllQuotaData() {
178
375
  if (!this.accounts.length) return;
179
376
  const { ok, data } = await this.api('/accounts/quota/all');
@@ -430,20 +627,34 @@ document.addEventListener('alpine:init', () => {
430
627
 
431
628
  async testChat() {
432
629
  if (!this.testPrompt.trim()) return;
630
+ const startedAt = Date.now();
433
631
  this.testing = true;
434
632
  this.testResponse = '';
435
- const { ok, data } = await this.api('/v1/chat/completions', {
436
- method: 'POST',
437
- body: JSON.stringify({
438
- model: 'gpt-5.5',
439
- messages: [{ role: 'user', content: this.testPrompt }]
440
- })
441
- });
442
- this.testing = false;
443
- if (ok && data.choices) {
444
- this.testResponse = data.choices[0].message.content;
445
- } else {
446
- this.testResponse = data?.error?.message || 'Request failed';
633
+ this.testStatus = 'running';
634
+ this.testError = '';
635
+ this.testMeta = null;
636
+
637
+ try {
638
+ const { ok, data, error } = await this.api('/v1/chat/completions', {
639
+ method: 'POST',
640
+ body: JSON.stringify({
641
+ model: 'gpt-5.5',
642
+ messages: [{ role: 'user', content: this.testPrompt }]
643
+ })
644
+ });
645
+ const durationMs = Date.now() - startedAt;
646
+ this.testMeta = { durationMs, usage: data?.usage || null };
647
+
648
+ if (ok && data.choices) {
649
+ this.testResponse = data.choices[0].message.content;
650
+ this.testStatus = 'success';
651
+ } else {
652
+ this.testError = data?.error?.message || error || 'Request failed';
653
+ this.testResponse = this.testError;
654
+ this.testStatus = 'error';
655
+ }
656
+ } finally {
657
+ this.testing = false;
447
658
  }
448
659
  },
449
660
 
@@ -493,47 +704,131 @@ document.addEventListener('alpine:init', () => {
493
704
  }
494
705
  },
495
706
 
496
- async loadAccountStrategySetting() {
497
- const { ok, data } = await this.api('/settings/account-strategy');
498
- if (ok && data?.accountStrategy) {
499
- this.accountStrategy = data.accountStrategy;
500
- this.multiAccountRotationEnabled = data.rotationEnabled === true;
501
- }
707
+ modelOptionName(modelId) {
708
+ const option = this.openAiModelOptions.find((model) => model.id === modelId);
709
+ return option ? option.name : modelId;
710
+ },
711
+
712
+ modelMappingLabel(alias) {
713
+ const labels = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' };
714
+ return labels[alias] || alias;
502
715
  },
503
716
 
504
- async setAccountStrategy(strategy) {
505
- if (!this.multiAccountRotationEnabled || this.strategySaving || this.accountStrategy === strategy) return;
506
- this.strategySaving = true;
507
- const { ok, data } = await this.api('/settings/account-strategy', {
717
+ async loadModelMappingsSetting() {
718
+ const { ok, data } = await this.api('/settings/model-mappings');
719
+ if (!ok || !data?.modelMappings) return;
720
+
721
+ this.modelMappings = data.modelMappings;
722
+ this.modelMappingDefaults = data.defaults || this.modelMappingDefaults;
723
+ this.reasoningMappings = data.reasoningMappings || this.reasoningMappings;
724
+ this.reasoningMappingDefaults = data.reasoningDefaults || this.reasoningMappingDefaults;
725
+ this.openAiModelOptions = Array.isArray(data.models) ? data.models : [];
726
+ this.reasoningLevelOptions = Array.isArray(data.reasoningLevels) ? data.reasoningLevels : [];
727
+ },
728
+
729
+ async setModelMapping(alias, model) {
730
+ if (this.modelMappingSaving || !alias || !model) return;
731
+
732
+ const previous = this.modelMappings[alias];
733
+ if (previous === model) return;
734
+
735
+ this.modelMappings = { ...this.modelMappings, [alias]: model };
736
+ this.modelMappingSaving = alias;
737
+ const { ok, data } = await this.api('/settings/model-mappings', {
508
738
  method: 'POST',
509
- body: JSON.stringify({ accountStrategy: strategy })
739
+ body: JSON.stringify({ modelMappings: { [alias]: model } })
510
740
  });
511
- this.strategySaving = false;
512
- if (ok && data?.accountStrategy) {
513
- this.accountStrategy = data.accountStrategy;
514
- this.multiAccountRotationEnabled = data.rotationEnabled === true;
515
- this.showToast(`Account strategy set to ${data.accountStrategy === 'sticky' ? 'Sticky' : 'Round-Robin'}`, 'success');
741
+ this.modelMappingSaving = null;
742
+
743
+ if (ok && data?.modelMappings) {
744
+ this.modelMappings = data.modelMappings;
745
+ this.modelMappingDefaults = data.defaults || this.modelMappingDefaults;
746
+ this.reasoningMappings = data.reasoningMappings || this.reasoningMappings;
747
+ this.reasoningMappingDefaults = data.reasoningDefaults || this.reasoningMappingDefaults;
748
+ this.openAiModelOptions = Array.isArray(data.models) ? data.models : this.openAiModelOptions;
749
+ this.reasoningLevelOptions = Array.isArray(data.reasoningLevels) ? data.reasoningLevels : this.reasoningLevelOptions;
750
+ this.showToast(`${this.modelMappingLabel(alias)} now maps to ${this.modelOptionName(data.modelMappings[alias])}`, 'success');
516
751
  } else {
517
- this.showToast(data?.error || 'Failed to update strategy', 'error');
752
+ this.modelMappings = { ...this.modelMappings, [alias]: previous };
753
+ this.showToast(data?.error || 'Failed to update model mapping', 'error');
518
754
  }
519
755
  },
520
756
 
521
- async setClaudeCodeProxyTestConfig() {
522
- const { ok, data, error } = await this.api('/claude/config/set', {
757
+ reasoningOptionName(reasoningId) {
758
+ const option = this.reasoningLevelOptions.find((level) => level.id === reasoningId);
759
+ return option ? option.name : reasoningId;
760
+ },
761
+
762
+ async setReasoningMapping(alias, reasoning) {
763
+ if (this.reasoningMappingSaving || !alias || !reasoning) return;
764
+
765
+ const previous = this.reasoningMappings[alias];
766
+ if (previous === reasoning) return;
767
+
768
+ this.reasoningMappings = { ...this.reasoningMappings, [alias]: reasoning };
769
+ this.reasoningMappingSaving = alias;
770
+ const { ok, data } = await this.api('/settings/model-mappings', {
523
771
  method: 'POST',
524
- body: JSON.stringify({
525
- apiUrl: 'http://localhost:8081',
526
- apiKey: 'test'
527
- })
772
+ body: JSON.stringify({ reasoningMappings: { [alias]: reasoning } })
528
773
  });
774
+ this.reasoningMappingSaving = null;
775
+
776
+ if (ok && data?.reasoningMappings) {
777
+ this.reasoningMappings = data.reasoningMappings;
778
+ this.reasoningMappingDefaults = data.reasoningDefaults || this.reasoningMappingDefaults;
779
+ this.reasoningLevelOptions = Array.isArray(data.reasoningLevels) ? data.reasoningLevels : this.reasoningLevelOptions;
780
+ this.showToast(`${this.modelMappingLabel(alias)} reasoning set to ${this.reasoningOptionName(data.reasoningMappings[alias])}`, 'success');
781
+ } else {
782
+ this.reasoningMappings = { ...this.reasoningMappings, [alias]: previous };
783
+ this.showToast(data?.error || 'Failed to update reasoning level', 'error');
784
+ }
785
+ },
786
+
787
+ async loadClaudeProxySetting() {
788
+ const { ok, data } = await this.api('/settings/claude-proxy');
789
+ if (ok && typeof data?.configureClaudeOnStartup === 'boolean') {
790
+ this.configureClaudeOnStartup = data.configureClaudeOnStartup;
791
+ }
792
+ },
793
+
794
+ async configureClaudeProxy() {
795
+ if (this.claudeProxyConfiguring) return;
796
+ this.claudeProxyConfiguring = true;
797
+ const { ok, data, error } = await this.api('/claude/config/proxy', { method: 'POST' });
798
+ this.claudeProxyConfiguring = false;
529
799
 
530
800
  if (ok && data?.success) {
531
- this.showToast('Updated Claude Code settings.json (API URL + API key).', 'success');
801
+ this.showToast(data.message || 'Claude Code configured to use this proxy.', 'success');
532
802
  } else {
533
803
  this.showToast(data?.error || error || 'Failed to update Claude Code settings.json', 'error');
534
804
  }
535
805
  },
536
806
 
807
+ async setConfigureClaudeOnStartup(enabled) {
808
+ if (this.claudeProxyStartupSaving) return;
809
+ const previous = this.configureClaudeOnStartup;
810
+ this.configureClaudeOnStartup = enabled;
811
+ this.claudeProxyStartupSaving = true;
812
+ const { ok, data, error } = await this.api('/settings/claude-proxy', {
813
+ method: 'POST',
814
+ body: JSON.stringify({ configureClaudeOnStartup: enabled })
815
+ });
816
+ this.claudeProxyStartupSaving = false;
817
+
818
+ if (ok && typeof data?.configureClaudeOnStartup === 'boolean') {
819
+ this.configureClaudeOnStartup = data.configureClaudeOnStartup;
820
+ this.showToast(
821
+ data.configureClaudeOnStartup
822
+ ? 'Claude Code will be configured on proxy startup.'
823
+ : 'Startup Claude Code configuration disabled.',
824
+ 'success'
825
+ );
826
+ } else {
827
+ this.configureClaudeOnStartup = previous;
828
+ this.showToast(data?.error || error || 'Failed to update startup setting', 'error');
829
+ }
830
+ },
831
+
537
832
  showToast(message, type = 'success') {
538
833
  this.toast = { message, type };
539
834
  setTimeout(() => { this.toast = null; }, 3000);
@@ -541,11 +836,16 @@ document.addEventListener('alpine:init', () => {
541
836
 
542
837
  startLogStream() {
543
838
  if (this.logEventSource) this.logEventSource.close();
544
-
839
+
840
+ this.logStreamStatus = 'connecting';
545
841
  this.logEventSource = new EventSource('/api/logs/stream?history=true');
842
+ this.logEventSource.onopen = () => {
843
+ this.logStreamStatus = 'connected';
844
+ };
546
845
  this.logEventSource.onmessage = (event) => {
547
846
  try {
548
847
  const log = JSON.parse(event.data);
848
+ this.logStreamStatus = 'connected';
549
849
  this.logs.unshift(log);
550
850
 
551
851
  if (this.logs.length > 500) {
@@ -555,6 +855,8 @@ document.addEventListener('alpine:init', () => {
555
855
  };
556
856
 
557
857
  this.logEventSource.onerror = () => {
858
+ this.logStreamStatus = 'disconnected';
859
+ if (this.logEventSource) this.logEventSource.close();
558
860
  setTimeout(() => this.startLogStream(), 3000);
559
861
  };
560
862
  },
@@ -572,6 +874,22 @@ document.addEventListener('alpine:init', () => {
572
874
  return message;
573
875
  },
574
876
 
877
+ formatLogTime(timestamp) {
878
+ if (!timestamp) return '--:--:--';
879
+ const date = new Date(timestamp);
880
+ if (Number.isNaN(date.getTime())) return '--:--:--';
881
+ return date.toLocaleTimeString([], { hour12: false });
882
+ },
883
+
884
+ logStreamStatusText() {
885
+ const labels = {
886
+ connecting: 'Connecting',
887
+ connected: 'Live',
888
+ disconnected: 'Reconnecting'
889
+ };
890
+ return labels[this.logStreamStatus] || 'Unknown';
891
+ },
892
+
575
893
  getLogDetails(message) {
576
894
  if (!message) return null;
577
895
  const details = {};