@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.
- package/dist/controllers/telescope.controller.d.ts +1 -1
- package/dist/controllers/telescope.controller.js +7 -5
- package/dist/storage/telescope-repository.service.d.ts +1 -0
- package/dist/storage/telescope-repository.service.js +3 -0
- package/dist/telescope.service.d.ts +12 -0
- package/dist/telescope.service.js +20 -4
- package/dist/ui/index.html +161 -12
- package/dist/ui/manifest.json +2 -2
- package/dist/watchers/http-request.watcher.js +41 -32
- package/package.json +1 -1
- package/ui/index.html +161 -12
|
@@ -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)('
|
|
124
|
-
__param(3, (0, common_1.Query)('
|
|
125
|
-
__param(4, (0, common_1.Query)('
|
|
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([
|
|
@@ -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
|
-
|
|
113
|
+
const store = this.asyncLocalStorage.getStore();
|
|
114
|
+
if (store && store.isRecording)
|
|
107
115
|
return;
|
|
108
|
-
|
|
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:
|
|
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
|
-
|
|
159
|
+
const store = this.asyncLocalStorage.getStore();
|
|
160
|
+
return !!(store && store.isRecording);
|
|
145
161
|
}
|
|
146
162
|
};
|
|
147
163
|
exports.TelescopeService = TelescopeService;
|
package/dist/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
|
-
<
|
|
1310
|
-
<div className="key-val-
|
|
1311
|
-
<div className="key-val-
|
|
1312
|
-
|
|
1313
|
-
{
|
|
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
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
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':
|
package/dist/ui/manifest.json
CHANGED
|
@@ -47,41 +47,50 @@ let HttpRequestWatcher = class HttpRequestWatcher {
|
|
|
47
47
|
query: request.query,
|
|
48
48
|
ip: request.ip,
|
|
49
49
|
};
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}));
|
|
90
|
+
}).catch(() => { });
|
|
91
|
+
},
|
|
92
|
+
}));
|
|
93
|
+
});
|
|
85
94
|
}
|
|
86
95
|
};
|
|
87
96
|
exports.HttpRequestWatcher = HttpRequestWatcher;
|
package/package.json
CHANGED
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
|
-
<
|
|
1310
|
-
<div className="key-val-
|
|
1311
|
-
<div className="key-val-
|
|
1312
|
-
|
|
1313
|
-
{
|
|
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
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
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':
|