@ngn-net/nestjs-telescope 0.2.13 → 0.2.15

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.
@@ -11,7 +11,7 @@ export declare class TelescopeController {
11
11
  token: string;
12
12
  }>;
13
13
  getStats(): Promise<Record<string, number>>;
14
- getEntries(type?: string, search?: string, page?: string, perPage?: string, limit?: string): Promise<import("../storage/telescope-repository.service").PaginatedResult>;
14
+ getEntries(type?: string, search?: string, batchId?: string, page?: string, perPage?: string, limit?: string): Promise<import("../storage/telescope-repository.service").PaginatedResult>;
15
15
  getEntry(uuid: string): Promise<import("..").TelescopeEntry>;
16
16
  clearEntries(): Promise<{
17
17
  success: boolean;
@@ -43,13 +43,14 @@ let TelescopeController = class TelescopeController {
43
43
  async getStats() {
44
44
  return await this.repo.countByType();
45
45
  }
46
- async getEntries(type, search, page, perPage, limit) {
46
+ async getEntries(type, search, batchId, page, perPage, limit) {
47
47
  const pageNum = page ? parseInt(page, 10) : 1;
48
48
  const limitNum = limit ? parseInt(limit, 10) : undefined;
49
49
  const perPageNum = perPage ? parseInt(perPage, 10) : (limitNum || 50);
50
50
  return await this.repo.queryEntries({
51
51
  type,
52
52
  search,
53
+ batchId,
53
54
  page: pageNum,
54
55
  limit: perPageNum,
55
56
  });
@@ -120,11 +121,12 @@ __decorate([
120
121
  (0, common_1.Get)('api/entries'),
121
122
  __param(0, (0, common_1.Query)('type')),
122
123
  __param(1, (0, common_1.Query)('search')),
123
- __param(2, (0, common_1.Query)('page')),
124
- __param(3, (0, common_1.Query)('perPage')),
125
- __param(4, (0, common_1.Query)('limit')),
124
+ __param(2, (0, common_1.Query)('batchId')),
125
+ __param(3, (0, common_1.Query)('page')),
126
+ __param(4, (0, common_1.Query)('perPage')),
127
+ __param(5, (0, common_1.Query)('limit')),
126
128
  __metadata("design:type", Function),
127
- __metadata("design:paramtypes", [String, String, String, String, String]),
129
+ __metadata("design:paramtypes", [String, String, String, String, String, String]),
128
130
  __metadata("design:returntype", Promise)
129
131
  ], TelescopeController.prototype, "getEntries", null);
130
132
  __decorate([
@@ -4,6 +4,7 @@ import { EntryType } from '../enums/entry-type.enum';
4
4
  export interface QueryFilters {
5
5
  type?: string;
6
6
  search?: string;
7
+ batchId?: string;
7
8
  limit?: number;
8
9
  page?: number;
9
10
  }
@@ -58,6 +58,9 @@ let TelescopeRepository = class TelescopeRepository {
58
58
  if (filters.type) {
59
59
  qb.andWhere('entry.type = :type', { type: filters.type });
60
60
  }
61
+ if (filters.batchId) {
62
+ qb.andWhere('entry.batchId = :batchId', { batchId: filters.batchId });
63
+ }
61
64
  // For search, do in-memory filtering since LIKE on JSON text varies per DB
62
65
  if (filters.search) {
63
66
  const searchLower = `%${filters.search.toLowerCase()}%`;
@@ -4,6 +4,11 @@ import { TelescopeRepository } from './storage/telescope-repository.service';
4
4
  import { TelescopeEntry } from './storage/entities/telescope-entry.entity';
5
5
  import { EntryType } from './enums/entry-type.enum';
6
6
  import { TelescopeOptions } from './interfaces/telescope-options.interface';
7
+ export interface TelescopeRequestContext {
8
+ isRecording: boolean;
9
+ batchId?: string;
10
+ userId?: string;
11
+ }
7
12
  export declare class TelescopeService implements OnModuleInit {
8
13
  private readonly repo;
9
14
  private readonly options?;
@@ -12,6 +17,13 @@ export declare class TelescopeService implements OnModuleInit {
12
17
  private readonly asyncLocalStorage;
13
18
  constructor(repo: TelescopeRepository, options?: TelescopeOptions | undefined, adapterHost?: HttpAdapterHost | undefined);
14
19
  onModuleInit(): Promise<void>;
20
+ /**
21
+ * Run a function inside a specific request context.
22
+ */
23
+ runInRequestContext<T>(context: {
24
+ batchId: string;
25
+ userId?: string;
26
+ }, fn: () => T): T;
15
27
  /**
16
28
  * Get the full URL to the Telescope dashboard.
17
29
  */
@@ -18,6 +18,7 @@ const common_1 = require("@nestjs/common");
18
18
  const async_hooks_1 = require("async_hooks");
19
19
  const core_1 = require("@nestjs/core");
20
20
  const telescope_repository_service_1 = require("./storage/telescope-repository.service");
21
+ const entry_type_enum_1 = require("./enums/entry-type.enum");
21
22
  const constants_1 = require("./constants");
22
23
  let TelescopeService = class TelescopeService {
23
24
  repo;
@@ -33,6 +34,12 @@ let TelescopeService = class TelescopeService {
33
34
  async onModuleInit() {
34
35
  // Module is initialized; TypeORM will handle schema sync
35
36
  }
37
+ /**
38
+ * Run a function inside a specific request context.
39
+ */
40
+ runInRequestContext(context, fn) {
41
+ return this.asyncLocalStorage.run({ isRecording: false, ...context }, fn);
42
+ }
36
43
  /**
37
44
  * Get the full URL to the Telescope dashboard.
38
45
  */
@@ -103,9 +110,17 @@ let TelescopeService = class TelescopeService {
103
110
  return;
104
111
  // Guard against recursive calls (e.g., LogWatcher triggering another record)
105
112
  // using AsyncLocalStorage to avoid blocking concurrent requests
106
- if (this.asyncLocalStorage.getStore())
113
+ const store = this.asyncLocalStorage.getStore();
114
+ if (store && store.isRecording)
107
115
  return;
108
- await this.asyncLocalStorage.run(true, async () => {
116
+ const currentContext = store || { isRecording: false };
117
+ const batchId = entry.batchId ?? currentContext.batchId;
118
+ if (entry.type === entry_type_enum_1.EntryType.REQUEST && currentContext.userId) {
119
+ if (!entry.content)
120
+ entry.content = {};
121
+ entry.content.user = { id: currentContext.userId };
122
+ }
123
+ await this.asyncLocalStorage.run({ ...currentContext, isRecording: true }, async () => {
109
124
  try {
110
125
  const uuidStr = entry.uuid ?? `${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
111
126
  const fullEntry = {
@@ -114,7 +129,7 @@ let TelescopeService = class TelescopeService {
114
129
  content: entry.content ?? {},
115
130
  recordedAt: entry.recordedAt ?? new Date(),
116
131
  familyHash: entry.familyHash ?? null,
117
- batchId: entry.batchId,
132
+ batchId: batchId ?? null,
118
133
  };
119
134
  await this.repo.save(fullEntry);
120
135
  // Throttled pruning: at most once every PRUNE_THROTTLE_MS
@@ -141,7 +156,8 @@ let TelescopeService = class TelescopeService {
141
156
  * This is useful for watchers (like LogWatcher) to prevent infinite loops.
142
157
  */
143
158
  isRecording() {
144
- return !!this.asyncLocalStorage.getStore();
159
+ const store = this.asyncLocalStorage.getStore();
160
+ return !!(store && store.isRecording);
145
161
  }
146
162
  };
147
163
  exports.TelescopeService = TelescopeService;
@@ -688,6 +688,10 @@
688
688
  border-bottom-color: var(--primary);
689
689
  }
690
690
 
691
+ .related-item-row:hover {
692
+ background-color: rgba(255, 255, 255, 0.04);
693
+ }
694
+
691
695
  .modal-body {
692
696
  flex-grow: 1;
693
697
  overflow-y: auto;
@@ -880,6 +884,9 @@
880
884
  const [polling, setPolling] = useState(true);
881
885
  const [selectedEntry, setSelectedEntry] = useState(null);
882
886
  const [modalTab, setModalTab] = useState('details');
887
+ const [activeRelationTab, setActiveRelationTab] = useState('');
888
+ const [relatedEntries, setRelatedEntries] = useState([]);
889
+ const [loadingRelated, setLoadingRelated] = useState(false);
883
890
 
884
891
  // Pagination state
885
892
  const [currentPage, setCurrentPage] = useState(1);
@@ -896,6 +903,35 @@
896
903
  }
897
904
  }, [token, activeTab, searchTerm]);
898
905
 
906
+ // Fetch related batch entries when selectedEntry changes
907
+ useEffect(() => {
908
+ if (selectedEntry && selectedEntry.batchId && token) {
909
+ setLoadingRelated(true);
910
+ fetch(`/${prefixPath}/api/entries?batchId=${selectedEntry.batchId}&perPage=100`, {
911
+ headers: { 'Authorization': `Bearer ${token}` }
912
+ })
913
+ .then(res => res.json())
914
+ .then(data => {
915
+ const filtered = (data.data || []).filter(e => e.uuid !== selectedEntry.uuid);
916
+ setRelatedEntries(filtered);
917
+ if (filtered.length > 0) {
918
+ setActiveRelationTab(filtered[0].type);
919
+ } else {
920
+ setActiveRelationTab('');
921
+ }
922
+ setLoadingRelated(false);
923
+ })
924
+ .catch(() => {
925
+ setRelatedEntries([]);
926
+ setActiveRelationTab('');
927
+ setLoadingRelated(false);
928
+ });
929
+ } else {
930
+ setRelatedEntries([]);
931
+ setActiveRelationTab('');
932
+ }
933
+ }, [selectedEntry, token]);
934
+
899
935
  // Polling effect
900
936
  useEffect(() => {
901
937
  let interval;
@@ -1306,21 +1342,101 @@
1306
1342
 
1307
1343
  <div className="modal-body">
1308
1344
  {modalTab === 'details' ? (
1309
- <div className="key-val-list">
1310
- <div className="key-val-row">
1311
- <div className="key-val-label">Recorded At</div>
1312
- <div className="key-val-value" style={{ color: '#fff', fontWeight: '500' }}>
1313
- {formatTime(selectedEntry.recordedAt)}
1345
+ <React.Fragment>
1346
+ <div className="key-val-list">
1347
+ <div className="key-val-row">
1348
+ <div className="key-val-label">Recorded At</div>
1349
+ <div className="key-val-value" style={{ color: '#fff', fontWeight: '500' }}>
1350
+ {formatTime(selectedEntry.recordedAt)}
1351
+ </div>
1314
1352
  </div>
1315
- </div>
1316
- <div className="key-val-row">
1317
- <div className="key-val-label">Entry Type</div>
1318
- <div className="key-val-value" style={{ textTransform: 'capitalize', fontWeight: '500' }}>
1319
- {selectedEntry.type.replace('_', ' ')}
1353
+ <div className="key-val-row">
1354
+ <div className="key-val-label">Entry Type</div>
1355
+ <div className="key-val-value" style={{ textTransform: 'capitalize', fontWeight: '500' }}>
1356
+ {selectedEntry.type.replace('_', ' ')}
1357
+ </div>
1320
1358
  </div>
1359
+ {renderStructuredDetails(selectedEntry)}
1321
1360
  </div>
1322
- {renderStructuredDetails(selectedEntry)}
1323
- </div>
1361
+
1362
+ {/* Render related entries links */}
1363
+ {(() => {
1364
+ const relatedByType = {};
1365
+ relatedEntries.forEach(item => {
1366
+ if (!relatedByType[item.type]) {
1367
+ relatedByType[item.type] = [];
1368
+ }
1369
+ relatedByType[item.type].push(item);
1370
+ });
1371
+
1372
+ if (loadingRelated) {
1373
+ return (
1374
+ <div style={{ marginTop: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>
1375
+ Loading related entries...
1376
+ </div>
1377
+ );
1378
+ }
1379
+
1380
+ if (Object.keys(relatedByType).length === 0) {
1381
+ return null;
1382
+ }
1383
+
1384
+ return (
1385
+ <div style={{ marginTop: '2rem', borderTop: '1px solid var(--border-color)', paddingTop: '1.5rem' }}>
1386
+ <h4 style={{ fontSize: '1.05rem', fontWeight: '700', color: '#fff', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
1387
+ <span>🔗</span> Related Entries
1388
+ </h4>
1389
+ <div className="modal-tabs" style={{ marginBottom: '1rem', flexWrap: 'wrap', gap: '8px', padding: '0 8px', backgroundColor: 'transparent', borderBottom: 'none' }}>
1390
+ {Object.keys(relatedByType).map(type => (
1391
+ <div
1392
+ key={type}
1393
+ className={`modal-tab ${activeRelationTab === type ? 'active' : ''}`}
1394
+ onClick={() => setActiveRelationTab(type)}
1395
+ style={{ padding: '8px 16px', fontSize: '0.825rem', borderRadius: '4px', borderBottomWidth: '2px', height: 'auto' }}
1396
+ >
1397
+ {type.replace('_', ' ').toUpperCase()} ({relatedByType[type].length})
1398
+ </div>
1399
+ ))}
1400
+ </div>
1401
+ <div style={{ backgroundColor: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '0.5rem', border: '1px solid var(--border-color)' }}>
1402
+ {activeRelationTab && relatedByType[activeRelationTab] && (
1403
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
1404
+ {relatedByType[activeRelationTab].map(item => (
1405
+ <div
1406
+ key={item.uuid}
1407
+ className="related-item-row"
1408
+ style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', padding: '0.625rem 0.875rem', borderRadius: '6px', transition: 'background-color 0.2s' }}
1409
+ onClick={() => {
1410
+ setSelectedEntry(item);
1411
+ setModalTab('details');
1412
+ }}
1413
+ >
1414
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', flex: 1, minWidth: 0 }}>
1415
+ <span style={{ color: '#fff', fontSize: '0.85rem', fontWeight: '500', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontFamily: item.type === 'query' ? 'var(--font-mono)' : 'inherit' }}>
1416
+ {item.type === 'query' ? (item.content.query || 'DB Query') :
1417
+ item.type === 'log' ? (item.content.message || 'Log') :
1418
+ item.type === 'model' ? (`Model ${item.content.action?.toUpperCase()}: ${item.content.entity}`) :
1419
+ item.type === 'http_client' ? (`${item.content.method || 'GET'} ${item.content.url}`) :
1420
+ item.type === 'cache' ? (`Cache ${item.content.action?.toUpperCase()}: ${item.content.key}`) :
1421
+ item.type === 'redis' ? (`Redis: ${item.content.command}`) :
1422
+ JSON.stringify(item.content)}
1423
+ </span>
1424
+ {item.type === 'query' && item.content.duration !== undefined && (
1425
+ <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
1426
+ Duration: {item.content.duration}ms
1427
+ </span>
1428
+ )}
1429
+ </div>
1430
+ <span style={{ color: 'var(--text-muted)', marginLeft: '10px' }}>→</span>
1431
+ </div>
1432
+ ))}
1433
+ </div>
1434
+ )}
1435
+ </div>
1436
+ </div>
1437
+ );
1438
+ })()}
1439
+ </React.Fragment>
1324
1440
  ) : (
1325
1441
  <pre className="json-code">
1326
1442
  {JSON.stringify(selectedEntry.content, null, 2)}
@@ -1525,6 +1641,39 @@
1525
1641
  <pre className="json-code">{JSON.stringify(content.response, null, 2)}</pre>
1526
1642
  </div>
1527
1643
  )}
1644
+ {content.user && (
1645
+ <div className="key-val-row" style={{ flexDirection: 'column', display: 'flex', gap: '8px' }}>
1646
+ <div className="key-val-label" style={{ color: '#10b981', fontWeight: '600' }}>Authenticated User</div>
1647
+ <div className="json-code" style={{ borderColor: 'rgba(16, 185, 129, 0.3)', color: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.03)', padding: '12px', overflow: 'hidden' }}>
1648
+ <table style={{ width: '100%', borderCollapse: 'collapse', margin: 0 }}>
1649
+ <tbody>
1650
+ {Object.entries(content.user).map(([key, value]) => {
1651
+ let displayKey = key;
1652
+ if (key === 'id' || key === 'sub' || key === 'userId') displayKey = 'ID';
1653
+ else if (key === 'email') displayKey = 'Email Address';
1654
+ else displayKey = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1').trim();
1655
+
1656
+ let displayValue = String(value);
1657
+ if (typeof value === 'object' && value !== null) {
1658
+ displayValue = JSON.stringify(value);
1659
+ }
1660
+
1661
+ return (
1662
+ <tr key={key} style={{ borderBottom: '1px solid rgba(16, 185, 129, 0.1)', backgroundColor: 'transparent' }}>
1663
+ <td style={{ padding: '8px', width: '200px', fontWeight: '600', color: '#10b981', borderBottom: 'none', textTransform: 'none', letterSpacing: 'normal', backgroundColor: 'transparent' }}>
1664
+ {displayKey}
1665
+ </td>
1666
+ <td style={{ padding: '8px', color: '#e2e8f0', borderBottom: 'none' }}>
1667
+ {displayValue}
1668
+ </td>
1669
+ </tr>
1670
+ );
1671
+ })}
1672
+ </tbody>
1673
+ </table>
1674
+ </div>
1675
+ </div>
1676
+ )}
1528
1677
  </React.Fragment>
1529
1678
  );
1530
1679
  case 'http_client':
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ngn-net/nestjs-telescope",
3
- "version": "0.2.13",
4
- "builtAt": "2026-06-08T12:58:05.511Z"
3
+ "version": "0.2.15",
4
+ "builtAt": "2026-06-08T13:13:50.492Z"
5
5
  }
@@ -47,41 +47,50 @@ let HttpRequestWatcher = class HttpRequestWatcher {
47
47
  query: request.query,
48
48
  ip: request.ip,
49
49
  };
50
- return next.handle().pipe((0, operators_1.tap)({
51
- next: (responseBody) => {
52
- const duration = Date.now() - start;
53
- const statusCode = response.statusCode || 200;
54
- this.telescope.record({
55
- type: entry_type_enum_1.EntryType.REQUEST,
56
- content: {
57
- request: requestData,
58
- response: {
59
- statusCode,
60
- body: responseBody,
50
+ const batchId = `${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
51
+ const user = request.user || request.user;
52
+ const userId = user ? String(user.id || user.sub || user.userId || user.username || '') : undefined;
53
+ return this.telescope.runInRequestContext({ batchId, userId }, () => {
54
+ return next.handle().pipe((0, operators_1.tap)({
55
+ next: (responseBody) => {
56
+ const duration = Date.now() - start;
57
+ const statusCode = response.statusCode || 200;
58
+ this.telescope.record({
59
+ type: entry_type_enum_1.EntryType.REQUEST,
60
+ batchId,
61
+ content: {
62
+ request: requestData,
63
+ response: {
64
+ statusCode,
65
+ body: responseBody,
66
+ },
67
+ user,
68
+ duration,
61
69
  },
62
- duration,
63
- },
64
- }).catch(() => { });
65
- },
66
- error: (error) => {
67
- const duration = Date.now() - start;
68
- const statusCode = error.status || error.statusCode || 500;
69
- this.telescope.record({
70
- type: entry_type_enum_1.EntryType.REQUEST,
71
- content: {
72
- request: requestData,
73
- response: {
74
- statusCode,
75
- body: {
76
- message: error.message || String(error),
77
- error: error.name || 'Error',
70
+ }).catch(() => { });
71
+ },
72
+ error: (error) => {
73
+ const duration = Date.now() - start;
74
+ const statusCode = error.status || error.statusCode || 500;
75
+ this.telescope.record({
76
+ type: entry_type_enum_1.EntryType.REQUEST,
77
+ batchId,
78
+ content: {
79
+ request: requestData,
80
+ response: {
81
+ statusCode,
82
+ body: {
83
+ message: error.message || String(error),
84
+ error: error.name || 'Error',
85
+ },
78
86
  },
87
+ user,
88
+ duration,
79
89
  },
80
- duration,
81
- },
82
- }).catch(() => { });
83
- },
84
- }));
90
+ }).catch(() => { });
91
+ },
92
+ }));
93
+ });
85
94
  }
86
95
  };
87
96
  exports.HttpRequestWatcher = HttpRequestWatcher;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngn-net/nestjs-telescope",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/ui/index.html CHANGED
@@ -688,6 +688,10 @@
688
688
  border-bottom-color: var(--primary);
689
689
  }
690
690
 
691
+ .related-item-row:hover {
692
+ background-color: rgba(255, 255, 255, 0.04);
693
+ }
694
+
691
695
  .modal-body {
692
696
  flex-grow: 1;
693
697
  overflow-y: auto;
@@ -880,6 +884,9 @@
880
884
  const [polling, setPolling] = useState(true);
881
885
  const [selectedEntry, setSelectedEntry] = useState(null);
882
886
  const [modalTab, setModalTab] = useState('details');
887
+ const [activeRelationTab, setActiveRelationTab] = useState('');
888
+ const [relatedEntries, setRelatedEntries] = useState([]);
889
+ const [loadingRelated, setLoadingRelated] = useState(false);
883
890
 
884
891
  // Pagination state
885
892
  const [currentPage, setCurrentPage] = useState(1);
@@ -896,6 +903,35 @@
896
903
  }
897
904
  }, [token, activeTab, searchTerm]);
898
905
 
906
+ // Fetch related batch entries when selectedEntry changes
907
+ useEffect(() => {
908
+ if (selectedEntry && selectedEntry.batchId && token) {
909
+ setLoadingRelated(true);
910
+ fetch(`/${prefixPath}/api/entries?batchId=${selectedEntry.batchId}&perPage=100`, {
911
+ headers: { 'Authorization': `Bearer ${token}` }
912
+ })
913
+ .then(res => res.json())
914
+ .then(data => {
915
+ const filtered = (data.data || []).filter(e => e.uuid !== selectedEntry.uuid);
916
+ setRelatedEntries(filtered);
917
+ if (filtered.length > 0) {
918
+ setActiveRelationTab(filtered[0].type);
919
+ } else {
920
+ setActiveRelationTab('');
921
+ }
922
+ setLoadingRelated(false);
923
+ })
924
+ .catch(() => {
925
+ setRelatedEntries([]);
926
+ setActiveRelationTab('');
927
+ setLoadingRelated(false);
928
+ });
929
+ } else {
930
+ setRelatedEntries([]);
931
+ setActiveRelationTab('');
932
+ }
933
+ }, [selectedEntry, token]);
934
+
899
935
  // Polling effect
900
936
  useEffect(() => {
901
937
  let interval;
@@ -1306,21 +1342,101 @@
1306
1342
 
1307
1343
  <div className="modal-body">
1308
1344
  {modalTab === 'details' ? (
1309
- <div className="key-val-list">
1310
- <div className="key-val-row">
1311
- <div className="key-val-label">Recorded At</div>
1312
- <div className="key-val-value" style={{ color: '#fff', fontWeight: '500' }}>
1313
- {formatTime(selectedEntry.recordedAt)}
1345
+ <React.Fragment>
1346
+ <div className="key-val-list">
1347
+ <div className="key-val-row">
1348
+ <div className="key-val-label">Recorded At</div>
1349
+ <div className="key-val-value" style={{ color: '#fff', fontWeight: '500' }}>
1350
+ {formatTime(selectedEntry.recordedAt)}
1351
+ </div>
1314
1352
  </div>
1315
- </div>
1316
- <div className="key-val-row">
1317
- <div className="key-val-label">Entry Type</div>
1318
- <div className="key-val-value" style={{ textTransform: 'capitalize', fontWeight: '500' }}>
1319
- {selectedEntry.type.replace('_', ' ')}
1353
+ <div className="key-val-row">
1354
+ <div className="key-val-label">Entry Type</div>
1355
+ <div className="key-val-value" style={{ textTransform: 'capitalize', fontWeight: '500' }}>
1356
+ {selectedEntry.type.replace('_', ' ')}
1357
+ </div>
1320
1358
  </div>
1359
+ {renderStructuredDetails(selectedEntry)}
1321
1360
  </div>
1322
- {renderStructuredDetails(selectedEntry)}
1323
- </div>
1361
+
1362
+ {/* Render related entries links */}
1363
+ {(() => {
1364
+ const relatedByType = {};
1365
+ relatedEntries.forEach(item => {
1366
+ if (!relatedByType[item.type]) {
1367
+ relatedByType[item.type] = [];
1368
+ }
1369
+ relatedByType[item.type].push(item);
1370
+ });
1371
+
1372
+ if (loadingRelated) {
1373
+ return (
1374
+ <div style={{ marginTop: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>
1375
+ Loading related entries...
1376
+ </div>
1377
+ );
1378
+ }
1379
+
1380
+ if (Object.keys(relatedByType).length === 0) {
1381
+ return null;
1382
+ }
1383
+
1384
+ return (
1385
+ <div style={{ marginTop: '2rem', borderTop: '1px solid var(--border-color)', paddingTop: '1.5rem' }}>
1386
+ <h4 style={{ fontSize: '1.05rem', fontWeight: '700', color: '#fff', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
1387
+ <span>🔗</span> Related Entries
1388
+ </h4>
1389
+ <div className="modal-tabs" style={{ marginBottom: '1rem', flexWrap: 'wrap', gap: '8px', padding: '0 8px', backgroundColor: 'transparent', borderBottom: 'none' }}>
1390
+ {Object.keys(relatedByType).map(type => (
1391
+ <div
1392
+ key={type}
1393
+ className={`modal-tab ${activeRelationTab === type ? 'active' : ''}`}
1394
+ onClick={() => setActiveRelationTab(type)}
1395
+ style={{ padding: '8px 16px', fontSize: '0.825rem', borderRadius: '4px', borderBottomWidth: '2px', height: 'auto' }}
1396
+ >
1397
+ {type.replace('_', ' ').toUpperCase()} ({relatedByType[type].length})
1398
+ </div>
1399
+ ))}
1400
+ </div>
1401
+ <div style={{ backgroundColor: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '0.5rem', border: '1px solid var(--border-color)' }}>
1402
+ {activeRelationTab && relatedByType[activeRelationTab] && (
1403
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
1404
+ {relatedByType[activeRelationTab].map(item => (
1405
+ <div
1406
+ key={item.uuid}
1407
+ className="related-item-row"
1408
+ style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', padding: '0.625rem 0.875rem', borderRadius: '6px', transition: 'background-color 0.2s' }}
1409
+ onClick={() => {
1410
+ setSelectedEntry(item);
1411
+ setModalTab('details');
1412
+ }}
1413
+ >
1414
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', flex: 1, minWidth: 0 }}>
1415
+ <span style={{ color: '#fff', fontSize: '0.85rem', fontWeight: '500', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontFamily: item.type === 'query' ? 'var(--font-mono)' : 'inherit' }}>
1416
+ {item.type === 'query' ? (item.content.query || 'DB Query') :
1417
+ item.type === 'log' ? (item.content.message || 'Log') :
1418
+ item.type === 'model' ? (`Model ${item.content.action?.toUpperCase()}: ${item.content.entity}`) :
1419
+ item.type === 'http_client' ? (`${item.content.method || 'GET'} ${item.content.url}`) :
1420
+ item.type === 'cache' ? (`Cache ${item.content.action?.toUpperCase()}: ${item.content.key}`) :
1421
+ item.type === 'redis' ? (`Redis: ${item.content.command}`) :
1422
+ JSON.stringify(item.content)}
1423
+ </span>
1424
+ {item.type === 'query' && item.content.duration !== undefined && (
1425
+ <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
1426
+ Duration: {item.content.duration}ms
1427
+ </span>
1428
+ )}
1429
+ </div>
1430
+ <span style={{ color: 'var(--text-muted)', marginLeft: '10px' }}>→</span>
1431
+ </div>
1432
+ ))}
1433
+ </div>
1434
+ )}
1435
+ </div>
1436
+ </div>
1437
+ );
1438
+ })()}
1439
+ </React.Fragment>
1324
1440
  ) : (
1325
1441
  <pre className="json-code">
1326
1442
  {JSON.stringify(selectedEntry.content, null, 2)}
@@ -1525,6 +1641,39 @@
1525
1641
  <pre className="json-code">{JSON.stringify(content.response, null, 2)}</pre>
1526
1642
  </div>
1527
1643
  )}
1644
+ {content.user && (
1645
+ <div className="key-val-row" style={{ flexDirection: 'column', display: 'flex', gap: '8px' }}>
1646
+ <div className="key-val-label" style={{ color: '#10b981', fontWeight: '600' }}>Authenticated User</div>
1647
+ <div className="json-code" style={{ borderColor: 'rgba(16, 185, 129, 0.3)', color: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.03)', padding: '12px', overflow: 'hidden' }}>
1648
+ <table style={{ width: '100%', borderCollapse: 'collapse', margin: 0 }}>
1649
+ <tbody>
1650
+ {Object.entries(content.user).map(([key, value]) => {
1651
+ let displayKey = key;
1652
+ if (key === 'id' || key === 'sub' || key === 'userId') displayKey = 'ID';
1653
+ else if (key === 'email') displayKey = 'Email Address';
1654
+ else displayKey = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1').trim();
1655
+
1656
+ let displayValue = String(value);
1657
+ if (typeof value === 'object' && value !== null) {
1658
+ displayValue = JSON.stringify(value);
1659
+ }
1660
+
1661
+ return (
1662
+ <tr key={key} style={{ borderBottom: '1px solid rgba(16, 185, 129, 0.1)', backgroundColor: 'transparent' }}>
1663
+ <td style={{ padding: '8px', width: '200px', fontWeight: '600', color: '#10b981', borderBottom: 'none', textTransform: 'none', letterSpacing: 'normal', backgroundColor: 'transparent' }}>
1664
+ {displayKey}
1665
+ </td>
1666
+ <td style={{ padding: '8px', color: '#e2e8f0', borderBottom: 'none' }}>
1667
+ {displayValue}
1668
+ </td>
1669
+ </tr>
1670
+ );
1671
+ })}
1672
+ </tbody>
1673
+ </table>
1674
+ </div>
1675
+ </div>
1676
+ )}
1528
1677
  </React.Fragment>
1529
1678
  );
1530
1679
  case 'http_client':