logler 1.0.7__cp311-cp311-win_amd64.whl
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.
- logler/__init__.py +22 -0
- logler/bootstrap.py +57 -0
- logler/cache.py +75 -0
- logler/cli.py +589 -0
- logler/helpers.py +282 -0
- logler/investigate.py +3962 -0
- logler/llm_cli.py +1426 -0
- logler/log_reader.py +267 -0
- logler/parser.py +207 -0
- logler/safe_regex.py +124 -0
- logler/terminal.py +252 -0
- logler/tracker.py +138 -0
- logler/tree_formatter.py +807 -0
- logler/watcher.py +55 -0
- logler/web/__init__.py +3 -0
- logler/web/app.py +810 -0
- logler/web/static/css/tailwind.css +1 -0
- logler/web/static/css/tailwind.input.css +3 -0
- logler/web/static/logler-logo.png +0 -0
- logler/web/tailwind.config.cjs +9 -0
- logler/web/templates/index.html +1454 -0
- logler-1.0.7.dist-info/METADATA +584 -0
- logler-1.0.7.dist-info/RECORD +28 -0
- logler-1.0.7.dist-info/WHEEL +4 -0
- logler-1.0.7.dist-info/entry_points.txt +2 -0
- logler-1.0.7.dist-info/licenses/LICENSE +21 -0
- logler_rs/__init__.py +5 -0
- logler_rs/logler_rs.cp311-win_amd64.pyd +0 -0
|
@@ -0,0 +1,1454 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Logler - Beautiful Log Viewer</title>
|
|
7
|
+
<link rel="icon" type="image/png" href="{{ url_for('static', path='logler-logo.png') }}">
|
|
8
|
+
<link rel="stylesheet" href="{{ url_for('static', path='css/tailwind.css') }}">
|
|
9
|
+
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
10
|
+
<script src="https://unpkg.com/htmx-ext-ws@2.0.1/ws.js"></script>
|
|
11
|
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
12
|
+
|
|
13
|
+
<style>
|
|
14
|
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
|
|
15
|
+
|
|
16
|
+
[x-cloak] { display: none !important; }
|
|
17
|
+
|
|
18
|
+
body {
|
|
19
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
20
|
+
background: radial-gradient(circle at 20% 20%, rgba(102,126,234,0.08), transparent 25%),
|
|
21
|
+
radial-gradient(circle at 80% 0%, rgba(118,75,162,0.08), transparent 20%),
|
|
22
|
+
linear-gradient(135deg, #0f141f 0%, #0b101a 100%);
|
|
23
|
+
min-height: 100vh;
|
|
24
|
+
color: #e5edff;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.log-viewer {
|
|
28
|
+
font-family: 'JetBrains Mono', monospace;
|
|
29
|
+
background: #1e1e1e;
|
|
30
|
+
color: #d4d4d4;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.log-line {
|
|
34
|
+
padding: 0.5rem;
|
|
35
|
+
border-bottom: 1px solid #2d2d2d;
|
|
36
|
+
transition: background 0.2s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.log-line:hover {
|
|
40
|
+
background: #2d2d2d;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.level-TRACE { color: #808080; }
|
|
44
|
+
.level-DEBUG { color: #4FC3F7; }
|
|
45
|
+
.level-INFO { color: #66BB6A; }
|
|
46
|
+
.level-WARN, .level-WARNING { color: #FFA726; }
|
|
47
|
+
.level-ERROR { color: #EF5350; }
|
|
48
|
+
.level-CRITICAL, .level-FATAL { color: #F44336; font-weight: bold; }
|
|
49
|
+
|
|
50
|
+
.glass-card {
|
|
51
|
+
background: rgba(18, 24, 38, 0.65);
|
|
52
|
+
backdrop-filter: blur(12px);
|
|
53
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
54
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.file-item {
|
|
58
|
+
transition: all 0.2s;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.file-item:hover {
|
|
62
|
+
transform: translateX(4px);
|
|
63
|
+
background: rgba(255, 255, 255, 0.1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.thread-badge {
|
|
67
|
+
display: inline-flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
padding: 0.15rem 0.4rem;
|
|
70
|
+
border-radius: 0.3rem;
|
|
71
|
+
font-size: 0.7rem;
|
|
72
|
+
font-weight: 700;
|
|
73
|
+
background: rgba(99, 123, 255, 0.16);
|
|
74
|
+
color: #cdd8ff;
|
|
75
|
+
border: 1px solid rgba(99, 123, 255, 0.35);
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.stat-card {
|
|
80
|
+
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%);
|
|
81
|
+
backdrop-filter: blur(10px);
|
|
82
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
83
|
+
transition: transform 0.2s;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.stat-card:hover {
|
|
87
|
+
transform: translateY(-2px);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
::-webkit-scrollbar {
|
|
91
|
+
width: 8px;
|
|
92
|
+
height: 8px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
::-webkit-scrollbar-track {
|
|
96
|
+
background: #0f141f;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
::-webkit-scrollbar-thumb {
|
|
100
|
+
background: #4452a3;
|
|
101
|
+
border-radius: 6px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
::-webkit-scrollbar-thumb:hover {
|
|
105
|
+
background: #6274d3;
|
|
106
|
+
}
|
|
107
|
+
</style>
|
|
108
|
+
</head>
|
|
109
|
+
<body x-data="logViewer()" x-init="init()" class="overflow-hidden">
|
|
110
|
+
<!-- Header -->
|
|
111
|
+
<header class="glass-card text-white p-4">
|
|
112
|
+
<div class="container mx-auto flex items-center justify-between">
|
|
113
|
+
<div class="flex items-center space-x-3">
|
|
114
|
+
<img src="{{ url_for('static', path='logler-logo.png') }}" alt="Logler logo" class="h-8 w-8 rounded-md bg-white bg-opacity-5 shadow-lg" style="height:2rem;width:2rem;">
|
|
115
|
+
<h1 class="text-2xl font-bold">Logler</h1>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="flex items-center space-x-4">
|
|
118
|
+
<span x-show="websocketConnected" class="flex items-center space-x-2">
|
|
119
|
+
<span class="w-3 h-3 bg-green-400 rounded-full animate-pulse"></span>
|
|
120
|
+
<span class="text-sm">Live</span>
|
|
121
|
+
</span>
|
|
122
|
+
<button @click="showFilePicker = true" class="px-4 py-2 bg-white bg-opacity-20 hover:bg-opacity-30 rounded-lg transition">
|
|
123
|
+
📁 Open File
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</header>
|
|
128
|
+
|
|
129
|
+
<div class="flex h-[calc(100vh-73px)]">
|
|
130
|
+
<!-- Sidebar -->
|
|
131
|
+
<aside class="w-[21rem] glass-card text-white p-4 overflow-y-auto">
|
|
132
|
+
<div class="space-y-6">
|
|
133
|
+
<!-- Active Files -->
|
|
134
|
+
<div>
|
|
135
|
+
<h2 class="text-lg font-semibold mb-3 flex items-center">
|
|
136
|
+
<span class="mr-2">📄</span> Active Files
|
|
137
|
+
</h2>
|
|
138
|
+
<div x-show="interleaved && Object.keys(interleaveCounts).length > 0" class="flex flex-wrap gap-2 mb-3">
|
|
139
|
+
<template x-for="(count, path) in interleaveCounts" :key="'chip-' + path">
|
|
140
|
+
<span class="thread-badge bg-white bg-opacity-10 border-white/20 rounded-full px-2 py-1 text-xs" :title="path + ' — ' + count + ' rows'">
|
|
141
|
+
<span x-text="(path.split('/').pop() || path) + ' • ' + count"></span>
|
|
142
|
+
</span>
|
|
143
|
+
</template>
|
|
144
|
+
</div>
|
|
145
|
+
<div x-show="activeFiles.length === 0" class="text-sm text-gray-300">
|
|
146
|
+
No files open yet
|
|
147
|
+
</div>
|
|
148
|
+
<div class="space-y-2">
|
|
149
|
+
<template x-for="file in activeFiles" :key="file">
|
|
150
|
+
<div class="p-2 bg-white bg-opacity-10 rounded cursor-pointer hover:bg-opacity-20 transition relative" @click="selectFile(file)" :title="file">
|
|
151
|
+
<div class="text-sm font-mono truncate" x-text="file.split('/').pop()"></div>
|
|
152
|
+
<div x-show="interleaved && currentFiles.includes(file)" class="absolute top-1 right-1 text-[10px] text-amber-300" :title="(interleaveCounts[file] || 0) + ' rows'">
|
|
153
|
+
interleaved
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</template>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<!-- Statistics -->
|
|
161
|
+
<div x-show="stats.total > 0">
|
|
162
|
+
<h2 class="text-lg font-semibold mb-3 flex items-center">
|
|
163
|
+
<span class="mr-2">📊</span> Statistics
|
|
164
|
+
</h2>
|
|
165
|
+
<div class="grid grid-cols-2 gap-2">
|
|
166
|
+
<div class="stat-card p-3 rounded-lg">
|
|
167
|
+
<div class="text-xs text-gray-300">Total</div>
|
|
168
|
+
<div class="text-2xl font-bold" x-text="stats.total"></div>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="stat-card p-3 rounded-lg">
|
|
171
|
+
<div class="text-xs text-gray-300">Errors</div>
|
|
172
|
+
<div class="text-2xl font-bold text-red-400" x-text="stats.errors"></div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<!-- Filters -->
|
|
178
|
+
<div>
|
|
179
|
+
<h2 class="text-lg font-semibold mb-3 flex items-center">
|
|
180
|
+
<span class="mr-2">🔍</span> Filters
|
|
181
|
+
</h2>
|
|
182
|
+
<div class="space-y-3">
|
|
183
|
+
<div>
|
|
184
|
+
<input type="text" x-model="searchQuery" @keyup.enter="applyFilters()"
|
|
185
|
+
placeholder="Search logs..."
|
|
186
|
+
class="w-full px-3 py-2 bg-white bg-opacity-10 border border-white border-opacity-20 rounded focus:outline-none focus:ring-2 focus:ring-purple-400">
|
|
187
|
+
</div>
|
|
188
|
+
<div>
|
|
189
|
+
<input type="text" x-model="correlationFilter" @keyup.enter="applyFilters()"
|
|
190
|
+
placeholder="Filter by correlation ID..."
|
|
191
|
+
class="w-full px-3 py-2 bg-white bg-opacity-10 border border-white border-opacity-20 rounded focus:outline-none focus:ring-2 focus:ring-purple-400">
|
|
192
|
+
</div>
|
|
193
|
+
<div class="flex flex-wrap gap-2">
|
|
194
|
+
<template x-for="level in ['DEBUG', 'INFO', 'WARN', 'ERROR']">
|
|
195
|
+
<label class="flex items-center space-x-2 cursor-pointer">
|
|
196
|
+
<input type="checkbox" :value="level" x-model="selectedLevels" @change="applyFilters()"
|
|
197
|
+
class="rounded bg-white bg-opacity-10">
|
|
198
|
+
<span class="text-sm" x-text="level" :class="'level-' + level"></span>
|
|
199
|
+
</label>
|
|
200
|
+
</template>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<!-- Threads -->
|
|
206
|
+
<div x-show="threads.length > 0">
|
|
207
|
+
<div class="flex items-center justify-between mb-2">
|
|
208
|
+
<h2 class="text-lg font-semibold flex items-center">
|
|
209
|
+
<span class="mr-2">🧵</span> Threads
|
|
210
|
+
</h2>
|
|
211
|
+
<button class="text-xs px-2 py-1 bg-white bg-opacity-10 rounded hover:bg-opacity-20"
|
|
212
|
+
@click="clearThreadSelection()" x-show="selectedThreads.length > 0">
|
|
213
|
+
Clear selection
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
<div class="space-y-3">
|
|
217
|
+
<div x-show="selectedThreads.length > 0" class="flex flex-wrap gap-2">
|
|
218
|
+
<template x-for="threadId in selectedThreads" :key="'chip-' + threadId">
|
|
219
|
+
<span class="thread-badge bg-white bg-opacity-15 border-white/30 rounded-full px-2 py-1 text-xs flex items-center gap-2 max-w-full">
|
|
220
|
+
<span class="truncate max-w-[9rem]" :title="threadId" x-text="threadId"></span>
|
|
221
|
+
<button @click.stop="removeThread(threadId)" class="hover:text-red-300 focus:outline-none">✕</button>
|
|
222
|
+
</span>
|
|
223
|
+
</template>
|
|
224
|
+
</div>
|
|
225
|
+
<input type="text" x-model="threadSearch" @input="updateVisibleThreads()" placeholder="Search thread..."
|
|
226
|
+
class="w-full px-3 py-2 bg-white bg-opacity-10 border border-white border-opacity-20 rounded focus:outline-none focus:ring-2 focus:ring-purple-400">
|
|
227
|
+
<div id="thread-list" class="max-h-64 overflow-y-auto pr-1" @scroll.passive="updateVisibleThreads()">
|
|
228
|
+
<div :style="`height:${threadTopSpacer}px`"></div>
|
|
229
|
+
<template x-for="thread in visibleThreadsCache" :key="thread.thread_id">
|
|
230
|
+
<label class="flex items-center gap-2 p-2 bg-white bg-opacity-5 rounded cursor-pointer hover:bg-opacity-20 transition">
|
|
231
|
+
<input type="checkbox" :value="thread.thread_id" x-model="selectedThreads" @change="applyFilters()" class="accent-indigo-400">
|
|
232
|
+
<div class="flex-1 min-w-0 flex items-center justify-between gap-2">
|
|
233
|
+
<span class="thread-badge truncate" x-text="thread.thread_id" :title="thread.thread_id"></span>
|
|
234
|
+
<span class="text-[11px] text-gray-300 whitespace-nowrap" x-text="thread.log_count + ' logs'"></span>
|
|
235
|
+
<span x-show="thread.error_count > 0" class="text-[11px] text-red-300 whitespace-nowrap" x-text="thread.error_count + ' err'"></span>
|
|
236
|
+
</div>
|
|
237
|
+
</label>
|
|
238
|
+
</template>
|
|
239
|
+
<div :style="`height:${threadBottomSpacer}px`"></div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</aside>
|
|
245
|
+
|
|
246
|
+
<!-- Main Content -->
|
|
247
|
+
<main class="flex-1 flex flex-col overflow-hidden relative">
|
|
248
|
+
<div x-show="loading" class="absolute inset-0 bg-black bg-opacity-50 backdrop-blur-sm z-30 flex items-center justify-center">
|
|
249
|
+
<div class="flex flex-col items-center gap-3">
|
|
250
|
+
<div class="h-10 w-10 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
251
|
+
<div class="text-sm text-gray-200">Loading log...</div>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
<div x-show="indexing && !loading" class="absolute top-3 right-3 z-20 px-3 py-2 rounded-lg bg-white bg-opacity-10 border border-white/20 text-xs flex items-center gap-2">
|
|
255
|
+
<span class="h-3 w-3 rounded-full bg-amber-400 animate-pulse"></span>
|
|
256
|
+
<span>Indexing large log…</span>
|
|
257
|
+
</div>
|
|
258
|
+
<!-- Toolbar -->
|
|
259
|
+
<div class="glass-card text-white p-3 flex items-center justify-between">
|
|
260
|
+
<div class="flex items-center space-x-4">
|
|
261
|
+
<div class="text-sm space-y-1">
|
|
262
|
+
<div class="flex items-center gap-2 text-sm">
|
|
263
|
+
<span>Showing <span x-text="visibleEntries.length"></span></span>
|
|
264
|
+
<span x-show="totalAvailable > 0" class="text-xs text-gray-400">(of <span x-text="totalAvailable"></span> total)</span>
|
|
265
|
+
<span x-show="filteredEntries.length > MAX_ENTRIES" class="text-xs text-gray-400">(latest window)</span>
|
|
266
|
+
<span x-show="partialLoad" class="text-xs text-amber-300">Quick view</span>
|
|
267
|
+
</div>
|
|
268
|
+
<div class="text-xs text-gray-300" x-show="interleaved">
|
|
269
|
+
Interleaved: <span x-text="currentFiles.join(', ')"></span>
|
|
270
|
+
</div>
|
|
271
|
+
<div class="text-xs text-gray-300" x-show="!interleaved && currentFile">
|
|
272
|
+
File: <span x-text="currentFile"></span>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
<div class="flex items-center space-x-2">
|
|
277
|
+
<button x-show="interleaved && currentFiles.length > 0" @click="showInterleaveModal = true" class="px-3 py-1 bg-white bg-opacity-20 hover:bg-opacity-30 rounded text-sm transition">
|
|
278
|
+
Interleave details
|
|
279
|
+
</button>
|
|
280
|
+
<button x-show="partialLoad && !indexing" @click="loadFullCurrent()" class="px-3 py-1 bg-white bg-opacity-20 hover:bg-opacity-30 rounded text-sm transition">
|
|
281
|
+
Load full log
|
|
282
|
+
</button>
|
|
283
|
+
<button @click="autoScroll = !autoScroll"
|
|
284
|
+
:class="autoScroll ? 'bg-green-500' : 'bg-white bg-opacity-20'"
|
|
285
|
+
class="px-3 py-1 rounded text-sm hover:opacity-80 transition">
|
|
286
|
+
<span x-text="autoScroll ? '📌 Auto-scroll ON' : '📌 Auto-scroll OFF'"></span>
|
|
287
|
+
</button>
|
|
288
|
+
<button @click="toggleFollow()" x-show="currentFile && !interleaved"
|
|
289
|
+
class="px-3 py-1 bg-white bg-opacity-20 hover:bg-opacity-30 rounded text-sm transition">
|
|
290
|
+
<span x-text="isFollowing ? '⏹ Stop Following' : '🔄 Follow'"></span>
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<!-- View Mode Tabs -->
|
|
296
|
+
<div class="glass-card text-white px-3 py-2 flex items-center gap-2 border-t border-white/10">
|
|
297
|
+
<span class="text-xs text-gray-400">View:</span>
|
|
298
|
+
<button @click="viewMode = 'logs'"
|
|
299
|
+
:class="viewMode === 'logs' ? 'bg-indigo-600' : 'bg-white bg-opacity-10'"
|
|
300
|
+
class="px-3 py-1 rounded text-xs hover:bg-opacity-30 transition">
|
|
301
|
+
📜 Logs
|
|
302
|
+
</button>
|
|
303
|
+
<button @click="viewMode = 'hierarchy'; loadHierarchy()"
|
|
304
|
+
:class="viewMode === 'hierarchy' ? 'bg-indigo-600' : 'bg-white bg-opacity-10'"
|
|
305
|
+
class="px-3 py-1 rounded text-xs hover:bg-opacity-30 transition">
|
|
306
|
+
🌳 Hierarchy
|
|
307
|
+
</button>
|
|
308
|
+
<button @click="viewMode = 'waterfall'; loadHierarchy()"
|
|
309
|
+
:class="viewMode === 'waterfall' ? 'bg-indigo-600' : 'bg-white bg-opacity-10'"
|
|
310
|
+
class="px-3 py-1 rounded text-xs hover:bg-opacity-30 transition">
|
|
311
|
+
📊 Waterfall
|
|
312
|
+
</button>
|
|
313
|
+
<div x-show="viewMode !== 'logs'" class="flex-1"></div>
|
|
314
|
+
<div x-show="viewMode !== 'logs'" class="flex items-center gap-2">
|
|
315
|
+
<input type="text" x-model="hierarchyRootId" @keyup.enter="loadHierarchy()"
|
|
316
|
+
placeholder="Root ID (thread, correlation, trace)..."
|
|
317
|
+
class="px-2 py-1 text-xs bg-white bg-opacity-10 border border-white/20 rounded w-48">
|
|
318
|
+
<button @click="loadHierarchy()" class="px-2 py-1 bg-white bg-opacity-20 rounded text-xs hover:bg-opacity-30">
|
|
319
|
+
🔍 Load
|
|
320
|
+
</button>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<!-- Log Viewer -->
|
|
325
|
+
<div x-show="viewMode === 'logs'" class="flex-1 log-viewer overflow-auto" id="log-container" @scroll="handleScroll()">
|
|
326
|
+
<div x-show="!currentFile" class="flex items-center justify-center h-full">
|
|
327
|
+
<div class="text-center text-gray-500">
|
|
328
|
+
<img src="{{ url_for('static', path='logler-logo.png') }}" alt="Logler logo" class="h-8 w-8 mx-auto mb-4 opacity-90" style="height:2rem;width:2rem;">
|
|
329
|
+
<div class="text-xl mb-2">No file opened</div>
|
|
330
|
+
<div class="text-sm">Click "Open File" to get started</div>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
<div x-show="currentFile && visibleEntries.length === 0" class="flex items-center justify-center h-full">
|
|
335
|
+
<div class="text-center text-gray-500">
|
|
336
|
+
<div class="text-4xl mb-4">🔍</div>
|
|
337
|
+
<div class="text-lg">No logs match the current filter</div>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<div :style="`height:${logTopSpacer}px`"></div>
|
|
342
|
+
<template x-for="entry in visibleEntries" :key="entry.entry_id || entryKey(entry)">
|
|
343
|
+
<div class="log-line" :class="'level-' + entry.level">
|
|
344
|
+
<div class="flex items-start space-x-3">
|
|
345
|
+
<span class="text-gray-600 text-xs font-mono w-12 text-right flex-shrink-0" x-text="entry.line_number"></span>
|
|
346
|
+
<span class="text-gray-400 text-xs flex-shrink-0 w-36" x-text="formatTimestamp(entry.timestamp)"></span>
|
|
347
|
+
<span class="text-xs font-bold flex-shrink-0 w-16" :class="'level-' + entry.level" x-text="entry.level"></span>
|
|
348
|
+
<div class="flex-1 min-w-0">
|
|
349
|
+
<div class="break-words" x-text="entry.message"></div>
|
|
350
|
+
<div x-show="entry.thread_id || entry.correlation_id || entry.service_name" class="mt-1 flex items-center space-x-2 text-xs text-gray-500">
|
|
351
|
+
<span x-show="entry.thread_id" class="thread-badge" @click.stop="toggleThreadFromEntry(entry.thread_id)" x-text="'🧵 ' + entry.thread_id"></span>
|
|
352
|
+
<span x-show="entry.correlation_id" class="text-blue-400" x-text="'🔗 ' + entry.correlation_id"></span>
|
|
353
|
+
<span x-show="entry.service_name" class="text-green-300" x-text="'🛠 ' + entry.service_name"></span>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</template>
|
|
359
|
+
<div :style="`height:${logBottomSpacer}px`"></div>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
<!-- Hierarchy Tree View -->
|
|
363
|
+
<div x-show="viewMode === 'hierarchy'" class="flex-1 log-viewer overflow-auto p-4">
|
|
364
|
+
<div x-show="hierarchyLoading" class="flex items-center justify-center h-full">
|
|
365
|
+
<div class="flex flex-col items-center gap-3">
|
|
366
|
+
<div class="h-10 w-10 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
367
|
+
<div class="text-sm text-gray-200">Loading hierarchy...</div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<div x-show="!hierarchyLoading && hierarchyError" class="flex items-center justify-center h-full">
|
|
372
|
+
<div class="text-center text-red-400">
|
|
373
|
+
<div class="text-4xl mb-4">❌</div>
|
|
374
|
+
<div class="text-lg" x-text="hierarchyError"></div>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
<div x-show="!hierarchyLoading && !hierarchyError && !hierarchy" class="flex items-center justify-center h-full">
|
|
379
|
+
<div class="text-center text-gray-500">
|
|
380
|
+
<div class="text-4xl mb-4">🌳</div>
|
|
381
|
+
<div class="text-lg mb-2">No hierarchy loaded</div>
|
|
382
|
+
<div class="text-sm">Enter a root ID (thread, correlation, or trace) and click Load</div>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
<div x-show="!hierarchyLoading && !hierarchyError && hierarchy">
|
|
387
|
+
<!-- Summary Stats -->
|
|
388
|
+
<div class="grid grid-cols-4 gap-3 mb-4">
|
|
389
|
+
<div class="stat-card p-3 rounded-lg">
|
|
390
|
+
<div class="text-xs text-gray-300">Total Nodes</div>
|
|
391
|
+
<div class="text-xl font-bold" x-text="hierarchy?.total_nodes || 0"></div>
|
|
392
|
+
</div>
|
|
393
|
+
<div class="stat-card p-3 rounded-lg">
|
|
394
|
+
<div class="text-xs text-gray-300">Max Depth</div>
|
|
395
|
+
<div class="text-xl font-bold" x-text="hierarchy?.max_depth || 0"></div>
|
|
396
|
+
</div>
|
|
397
|
+
<div class="stat-card p-3 rounded-lg">
|
|
398
|
+
<div class="text-xs text-gray-300">Duration</div>
|
|
399
|
+
<div class="text-xl font-bold" x-text="formatDuration(hierarchy?.total_duration_ms)"></div>
|
|
400
|
+
</div>
|
|
401
|
+
<div class="stat-card p-3 rounded-lg" :class="(hierarchy?.error_nodes?.length > 0) ? 'border-red-500 border' : ''">
|
|
402
|
+
<div class="text-xs text-gray-300">Errors</div>
|
|
403
|
+
<div class="text-xl font-bold text-red-400" x-text="hierarchy?.error_nodes?.length || 0"></div>
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
<!-- Bottleneck Alert -->
|
|
408
|
+
<div x-show="hierarchy?.bottleneck" class="mb-4 p-3 bg-amber-900/30 border border-amber-500/50 rounded-lg">
|
|
409
|
+
<div class="flex items-center gap-2">
|
|
410
|
+
<span class="text-amber-400">⚠️</span>
|
|
411
|
+
<span class="font-bold text-amber-300">Bottleneck Detected:</span>
|
|
412
|
+
<span x-text="hierarchy?.bottleneck?.node_id"></span>
|
|
413
|
+
<span class="text-gray-400">-</span>
|
|
414
|
+
<span x-text="formatDuration(hierarchy?.bottleneck?.duration_ms)"></span>
|
|
415
|
+
<span class="text-gray-400">(<span x-text="hierarchy?.bottleneck?.percentage?.toFixed(1)"></span>%)</span>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
|
|
419
|
+
<!-- Interactive Tree -->
|
|
420
|
+
<div class="font-mono text-sm">
|
|
421
|
+
<template x-for="root in hierarchy?.roots || []" :key="root.id">
|
|
422
|
+
<div x-data="{ expanded: true }">
|
|
423
|
+
<div class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer"
|
|
424
|
+
@click="expanded = !expanded"
|
|
425
|
+
:class="root.error_count > 0 ? 'bg-red-900/20' : ''">
|
|
426
|
+
<span x-text="expanded ? '▼' : '▶'" class="text-gray-500 w-4"></span>
|
|
427
|
+
<span x-text="root.error_count > 0 ? '❌' : '📁'" class="w-5"></span>
|
|
428
|
+
<span class="font-bold text-green-400" x-text="root.id"></span>
|
|
429
|
+
<span class="text-gray-500">(<span x-text="root.entry_count"></span> entries)</span>
|
|
430
|
+
<span x-show="root.duration_ms" class="text-yellow-400" x-text="formatDuration(root.duration_ms)"></span>
|
|
431
|
+
<span class="text-gray-600 text-xs" x-text="root.node_type"></span>
|
|
432
|
+
</div>
|
|
433
|
+
<div x-show="expanded" class="ml-6 border-l border-gray-700">
|
|
434
|
+
<template x-for="child in root.children || []" :key="child.id">
|
|
435
|
+
<div x-html="renderHierarchyNode(child, 1)"></div>
|
|
436
|
+
</template>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
</template>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
<!-- Error Flow Analysis -->
|
|
443
|
+
<div x-show="errorAnalysis?.has_errors" class="mt-6 p-4 bg-red-900/20 border border-red-500/30 rounded-lg">
|
|
444
|
+
<h3 class="text-lg font-bold text-red-400 mb-3">🔍 Error Flow Analysis</h3>
|
|
445
|
+
|
|
446
|
+
<div x-show="errorAnalysis?.root_causes?.length > 0" class="mb-4">
|
|
447
|
+
<div class="text-sm font-bold text-red-300 mb-2">Root Cause(s):</div>
|
|
448
|
+
<template x-for="cause in errorAnalysis?.root_causes || []" :key="cause.node_id">
|
|
449
|
+
<div class="ml-4 p-2 bg-red-900/30 rounded mb-2">
|
|
450
|
+
<div class="flex items-center gap-2">
|
|
451
|
+
<span class="text-red-400">🔴</span>
|
|
452
|
+
<span class="font-bold" x-text="cause.node_id"></span>
|
|
453
|
+
<span x-show="cause.is_leaf" class="text-xs text-gray-400">(leaf node)</span>
|
|
454
|
+
</div>
|
|
455
|
+
<div class="text-xs text-gray-400 mt-1">
|
|
456
|
+
Path: <span x-text="cause.path?.join(' → ')"></span>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
</template>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
<div x-show="errorAnalysis?.recommendations?.length > 0">
|
|
463
|
+
<div class="text-sm font-bold text-amber-300 mb-2">💡 Recommendations:</div>
|
|
464
|
+
<ul class="ml-4 text-sm text-gray-300 space-y-1">
|
|
465
|
+
<template x-for="rec in errorAnalysis?.recommendations || []" :key="rec">
|
|
466
|
+
<li class="flex items-start gap-2">
|
|
467
|
+
<span>•</span>
|
|
468
|
+
<span x-text="rec"></span>
|
|
469
|
+
</li>
|
|
470
|
+
</template>
|
|
471
|
+
</ul>
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
<!-- Waterfall Timeline View -->
|
|
478
|
+
<div x-show="viewMode === 'waterfall'" class="flex-1 log-viewer overflow-auto p-4">
|
|
479
|
+
<div x-show="hierarchyLoading" class="flex items-center justify-center h-full">
|
|
480
|
+
<div class="flex flex-col items-center gap-3">
|
|
481
|
+
<div class="h-10 w-10 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
482
|
+
<div class="text-sm text-gray-200">Loading waterfall...</div>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
<div x-show="!hierarchyLoading && !hierarchy" class="flex items-center justify-center h-full">
|
|
487
|
+
<div class="text-center text-gray-500">
|
|
488
|
+
<div class="text-4xl mb-4">📊</div>
|
|
489
|
+
<div class="text-lg mb-2">No waterfall data</div>
|
|
490
|
+
<div class="text-sm">Enter a root ID and click Load</div>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
<div x-show="!hierarchyLoading && hierarchy">
|
|
495
|
+
<!-- Waterfall Header -->
|
|
496
|
+
<div class="mb-4 flex items-center justify-between">
|
|
497
|
+
<div>
|
|
498
|
+
<span class="text-lg font-bold">Timeline:</span>
|
|
499
|
+
<span x-text="hierarchyRootId" class="text-green-400 ml-2"></span>
|
|
500
|
+
<span class="text-gray-400 ml-2">(<span x-text="formatDuration(hierarchy?.total_duration_ms)"></span>)</span>
|
|
501
|
+
</div>
|
|
502
|
+
<div class="text-xs text-gray-400">
|
|
503
|
+
Detection: <span x-text="hierarchy?.detection_method"></span>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
|
|
507
|
+
<!-- Waterfall Bars -->
|
|
508
|
+
<div class="font-mono text-xs space-y-1">
|
|
509
|
+
<template x-for="node in flattenHierarchy()" :key="node.id">
|
|
510
|
+
<div class="flex items-center gap-2 py-1">
|
|
511
|
+
<div class="w-40 truncate text-right pr-2" :style="`padding-left: ${node.depth * 12}px`">
|
|
512
|
+
<span :class="node.error_count > 0 ? 'text-red-400' : 'text-gray-300'" x-text="node.id"></span>
|
|
513
|
+
</div>
|
|
514
|
+
<div class="flex-1 h-5 bg-gray-800 rounded relative overflow-hidden">
|
|
515
|
+
<div class="absolute h-full rounded"
|
|
516
|
+
:class="node.error_count > 0 ? 'bg-red-600' : (node.id === hierarchy?.bottleneck?.node_id ? 'bg-amber-600' : 'bg-indigo-600')"
|
|
517
|
+
:style="`left: ${getWaterfallBarLeft(node)}%; width: ${getWaterfallBarWidth(node)}%`">
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
<div class="w-16 text-right text-gray-400" x-text="formatDuration(node.duration_ms)"></div>
|
|
521
|
+
</div>
|
|
522
|
+
</template>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<!-- Legend -->
|
|
526
|
+
<div class="mt-4 flex items-center gap-4 text-xs text-gray-400">
|
|
527
|
+
<div class="flex items-center gap-1">
|
|
528
|
+
<div class="w-3 h-3 bg-indigo-600 rounded"></div>
|
|
529
|
+
<span>Normal</span>
|
|
530
|
+
</div>
|
|
531
|
+
<div class="flex items-center gap-1">
|
|
532
|
+
<div class="w-3 h-3 bg-amber-600 rounded"></div>
|
|
533
|
+
<span>Bottleneck</span>
|
|
534
|
+
</div>
|
|
535
|
+
<div class="flex items-center gap-1">
|
|
536
|
+
<div class="w-3 h-3 bg-red-600 rounded"></div>
|
|
537
|
+
<span>Error</span>
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
</main>
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
<!-- File Picker Modal -->
|
|
546
|
+
<div x-show="showFilePicker" x-cloak
|
|
547
|
+
class="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm flex items-center justify-center z-50"
|
|
548
|
+
@click.self="showFilePicker = false">
|
|
549
|
+
<div class="glass-card text-white p-6 rounded-xl max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
|
|
550
|
+
<div class="flex items-center justify-between mb-4">
|
|
551
|
+
<h2 class="text-2xl font-bold">📁 Open Log File</h2>
|
|
552
|
+
<div class="flex gap-2 text-xs">
|
|
553
|
+
<button @click="toggleBrowseMode('browse')" :class="browseMode === 'browse' ? 'bg-white bg-opacity-20' : 'bg-white bg-opacity-5'" class="px-2 py-1 rounded hover:bg-opacity-30">Browse</button>
|
|
554
|
+
<button @click="toggleBrowseMode('glob')" :class="browseMode === 'glob' ? 'bg-white bg-opacity-20' : 'bg-white bg-opacity-5'" class="px-2 py-1 rounded hover:bg-opacity-30">Glob search</button>
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
|
|
558
|
+
<div class="mb-4">
|
|
559
|
+
<template x-if="browseMode === 'browse'">
|
|
560
|
+
<input type="text" x-model="currentDir" @keyup.enter="browseDirectory(currentDir)"
|
|
561
|
+
placeholder="Enter directory path..."
|
|
562
|
+
class="w-full px-4 py-2 bg-white bg-opacity-10 border border-white border-opacity-20 rounded focus:outline-none focus:ring-2 focus:ring-purple-400">
|
|
563
|
+
</template>
|
|
564
|
+
<template x-if="browseMode === 'glob'">
|
|
565
|
+
<div class="flex gap-2">
|
|
566
|
+
<div class="flex-1">
|
|
567
|
+
<label class="text-xs text-gray-300 block mb-1">Pattern</label>
|
|
568
|
+
<input type="text" x-model="globPattern" @keyup.enter="searchGlob()"
|
|
569
|
+
placeholder="Pattern (e.g. **/*.log)"
|
|
570
|
+
class="w-full px-4 py-2 bg-white bg-opacity-10 border border-white border-opacity-20 rounded focus:outline-none focus:ring-2 focus:ring-purple-400">
|
|
571
|
+
</div>
|
|
572
|
+
<div class="flex-1">
|
|
573
|
+
<label class="text-xs text-gray-300 block mb-1">Base directory</label>
|
|
574
|
+
<input type="text" x-model="globBaseDir" @keyup.enter="searchGlob()"
|
|
575
|
+
placeholder="Base directory"
|
|
576
|
+
class="w-full px-4 py-2 bg-white bg-opacity-10 border border-white border-opacity-20 rounded focus:outline-none focus:ring-2 focus:ring-purple-400">
|
|
577
|
+
</div>
|
|
578
|
+
<button @click="searchGlob" class="px-3 py-2 bg-white bg-opacity-20 rounded hover:bg-opacity-30 self-end">Search</button>
|
|
579
|
+
</div>
|
|
580
|
+
</template>
|
|
581
|
+
</div>
|
|
582
|
+
|
|
583
|
+
<div class="flex-1 overflow-y-auto space-y-2 mb-4">
|
|
584
|
+
<div x-show="browseMode === 'browse'">
|
|
585
|
+
<div class="flex items-center flex-wrap gap-2 mb-3">
|
|
586
|
+
<template x-for="crumb in breadcrumbs()" :key="crumb.path">
|
|
587
|
+
<button @click="browseDirectory(crumb.path)" class="px-2 py-1 text-xs bg-white bg-opacity-10 rounded border border-white/10 hover:bg-opacity-20" x-text="crumb.label || crumb.path"></button>
|
|
588
|
+
</template>
|
|
589
|
+
</div>
|
|
590
|
+
<div x-show="parentDir" @click="browseDirectory(parentDir)"
|
|
591
|
+
class="file-item p-3 rounded cursor-pointer bg-white bg-opacity-5">
|
|
592
|
+
<span class="text-yellow-400">📁 ..</span>
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
<template x-for="dir in directories" :key="dir.path">
|
|
596
|
+
<div @click="browseDirectory(dir.path)"
|
|
597
|
+
class="file-item p-3 rounded cursor-pointer bg-white bg-opacity-5">
|
|
598
|
+
<div class="flex items-center space-x-2">
|
|
599
|
+
<span>📁</span>
|
|
600
|
+
<span class="font-mono text-sm" x-text="dir.name"></span>
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
</template>
|
|
604
|
+
|
|
605
|
+
<template x-for="file in files" :key="file.path">
|
|
606
|
+
<div class="file-item p-3 rounded bg-white bg-opacity-5 flex items-center justify-between">
|
|
607
|
+
<div class="flex items-center space-x-2 cursor-pointer" @click="openFile(file.path); showFilePicker = false">
|
|
608
|
+
<span>📄</span>
|
|
609
|
+
<div class="flex flex-col">
|
|
610
|
+
<span class="font-mono text-sm truncate" x-text="file.name"></span>
|
|
611
|
+
<span class="text-[11px] text-gray-400 truncate" x-text="file.path"></span>
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
<div class="flex items-center space-x-3">
|
|
615
|
+
<input type="checkbox" :value="file.path" x-model="selectedPaths">
|
|
616
|
+
<span class="text-xs text-gray-400" x-text="formatSize(file.size)"></span>
|
|
617
|
+
</div>
|
|
618
|
+
</div>
|
|
619
|
+
</template>
|
|
620
|
+
|
|
621
|
+
<div x-show="files.length === 0 && directories.length === 0" class="text-center text-gray-400 py-8">
|
|
622
|
+
No log files found in this directory
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
|
|
626
|
+
<div x-show="browseMode === 'glob'">
|
|
627
|
+
<div class="flex items-center flex-wrap gap-2 mb-3">
|
|
628
|
+
<span class="px-2 py-1 text-xs bg-white bg-opacity-10 rounded border border-white/10" x-text="'Base: ' + (globBaseDir || '.')"></span>
|
|
629
|
+
<template x-for="preset in globPresets" :key="preset">
|
|
630
|
+
<button @click="globPattern = preset; searchGlob()" class="px-2 py-1 text-xs bg-white bg-opacity-5 rounded hover:bg-opacity-15 border border-white/5" x-text="preset"></button>
|
|
631
|
+
</template>
|
|
632
|
+
</div>
|
|
633
|
+
<div x-show="globLoading" class="text-center text-gray-300 py-4">Searching…</div>
|
|
634
|
+
<div x-show="globError" class="text-center text-amber-300 text-xs py-2" x-text="globError"></div>
|
|
635
|
+
<template x-for="file in globResults" :key="file.path">
|
|
636
|
+
<div class="file-item p-3 rounded bg-white bg-opacity-5 flex items-center justify-between">
|
|
637
|
+
<div class="flex items-center space-x-2 cursor-pointer" @click="openFile(file.path); showFilePicker = false">
|
|
638
|
+
<span>📄</span>
|
|
639
|
+
<div class="flex flex-col">
|
|
640
|
+
<span class="font-mono text-sm truncate" x-text="file.name"></span>
|
|
641
|
+
<span class="text-[11px] text-gray-400 truncate" x-text="file.path"></span>
|
|
642
|
+
</div>
|
|
643
|
+
</div>
|
|
644
|
+
<div class="flex items-center space-x-3">
|
|
645
|
+
<input type="checkbox" :value="file.path" x-model="selectedPaths">
|
|
646
|
+
<span class="text-xs text-gray-400" x-text="formatSize(file.size)"></span>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
</template>
|
|
650
|
+
<div x-show="!globLoading && globResults.length === 0" class="text-center text-gray-400 py-8">
|
|
651
|
+
No files match this pattern
|
|
652
|
+
</div>
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
|
|
656
|
+
<div class="space-y-2">
|
|
657
|
+
<button @click="openSelected()"
|
|
658
|
+
class="w-full py-2 bg-green-500 hover:bg-green-600 rounded transition disabled:opacity-50"
|
|
659
|
+
:disabled="selectedPaths.length === 0">
|
|
660
|
+
Open Selected (interleave)
|
|
661
|
+
</button>
|
|
662
|
+
<button @click="showFilePicker = false"
|
|
663
|
+
class="w-full py-2 bg-white bg-opacity-20 hover:bg-opacity-30 rounded transition">
|
|
664
|
+
Close
|
|
665
|
+
</button>
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
</div>
|
|
669
|
+
|
|
670
|
+
<!-- Interleave Details Modal -->
|
|
671
|
+
<div x-show="showInterleaveModal" x-cloak
|
|
672
|
+
class="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm flex items-center justify-center z-50"
|
|
673
|
+
@click.self="showInterleaveModal = false">
|
|
674
|
+
<div class="glass-card text-white p-6 rounded-xl max-w-xl w-full mx-4 max-h-[70vh] overflow-y-auto">
|
|
675
|
+
<div class="flex items-center justify-between mb-4">
|
|
676
|
+
<h2 class="text-xl font-bold">Interleave Details</h2>
|
|
677
|
+
<button @click="showInterleaveModal = false" class="text-sm px-2 py-1 bg-white bg-opacity-10 rounded hover:bg-opacity-20">Close</button>
|
|
678
|
+
</div>
|
|
679
|
+
<template x-if="interleaveMeta.length === 0">
|
|
680
|
+
<div class="text-gray-300 text-sm">No metadata available.</div>
|
|
681
|
+
</template>
|
|
682
|
+
<div x-show="interleaveMeta.length > 0" class="space-y-3">
|
|
683
|
+
<template x-for="item in interleaveMeta" :key="item.file">
|
|
684
|
+
<div class="p-3 bg-white bg-opacity-5 rounded border border-white/10">
|
|
685
|
+
<div class="flex items-center justify-between text-sm">
|
|
686
|
+
<div class="font-mono truncate" :title="item.file" x-text="item.file.split('/').pop() || item.file"></div>
|
|
687
|
+
<div class="text-xs text-gray-300" x-text="(interleaveCounts[item.file] || item.count || 0) + ' rows'"></div>
|
|
688
|
+
</div>
|
|
689
|
+
<div class="mt-2 grid grid-cols-2 gap-2 text-xs text-gray-300">
|
|
690
|
+
<div>First: <span class="text-gray-100" x-text="item.first || 'N/A'"></span></div>
|
|
691
|
+
<div>Last: <span class="text-gray-100" x-text="item.last || 'N/A'"></span></div>
|
|
692
|
+
</div>
|
|
693
|
+
</div>
|
|
694
|
+
</template>
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
|
|
699
|
+
<script>
|
|
700
|
+
function logViewer() {
|
|
701
|
+
return {
|
|
702
|
+
MAX_ENTRIES: 10000,
|
|
703
|
+
LOG_ROW_HEIGHT: 56,
|
|
704
|
+
LOG_OVERSCAN: 20,
|
|
705
|
+
THREAD_ROW_HEIGHT: 40,
|
|
706
|
+
THREAD_OVERSCAN: 12,
|
|
707
|
+
showFilePicker: false,
|
|
708
|
+
currentDir: '.',
|
|
709
|
+
parentDir: null,
|
|
710
|
+
files: [],
|
|
711
|
+
directories: [],
|
|
712
|
+
activeFiles: [],
|
|
713
|
+
selectedPaths: [],
|
|
714
|
+
browseMode: 'browse',
|
|
715
|
+
globPattern: '**/*.log',
|
|
716
|
+
globBaseDir: '.',
|
|
717
|
+
globResults: [],
|
|
718
|
+
globLoading: false,
|
|
719
|
+
globError: '',
|
|
720
|
+
globPresets: ['*.log', '**/*.log', '**/app-*.log', '**/*error*.log'],
|
|
721
|
+
rootDir: '',
|
|
722
|
+
currentFile: null,
|
|
723
|
+
currentFiles: [],
|
|
724
|
+
interleaved: false,
|
|
725
|
+
interleaveCounts: {},
|
|
726
|
+
interleaveMeta: [],
|
|
727
|
+
entries: [],
|
|
728
|
+
filteredEntries: [],
|
|
729
|
+
visibleEntries: [],
|
|
730
|
+
// Hierarchy view state
|
|
731
|
+
viewMode: 'logs', // 'logs', 'hierarchy', 'waterfall'
|
|
732
|
+
hierarchyRootId: '',
|
|
733
|
+
hierarchy: null,
|
|
734
|
+
errorAnalysis: null,
|
|
735
|
+
hierarchyLoading: false,
|
|
736
|
+
hierarchyError: '',
|
|
737
|
+
hierarchyMinTime: null,
|
|
738
|
+
hierarchyMaxTime: null,
|
|
739
|
+
logTopSpacer: 0,
|
|
740
|
+
logBottomSpacer: 0,
|
|
741
|
+
logViewportHeight: 600,
|
|
742
|
+
logScrollScheduled: false,
|
|
743
|
+
threads: [],
|
|
744
|
+
threadSearch: '',
|
|
745
|
+
selectedThreads: [],
|
|
746
|
+
visibleThreadsCache: [],
|
|
747
|
+
threadTopSpacer: 0,
|
|
748
|
+
threadBottomSpacer: 0,
|
|
749
|
+
threadViewportHeight: 256,
|
|
750
|
+
searchQuery: '',
|
|
751
|
+
correlationFilter: '',
|
|
752
|
+
selectedLevels: [],
|
|
753
|
+
totalAvailable: 0,
|
|
754
|
+
autoScroll: false,
|
|
755
|
+
ignoreScrollEvents: false,
|
|
756
|
+
websocketConnected: false,
|
|
757
|
+
isFollowing: false,
|
|
758
|
+
loading: false,
|
|
759
|
+
indexing: false,
|
|
760
|
+
partialLoad: false,
|
|
761
|
+
skipFullIndex: true,
|
|
762
|
+
showInterleaveModal: false,
|
|
763
|
+
followSocket: null,
|
|
764
|
+
stats: {
|
|
765
|
+
total: 0,
|
|
766
|
+
errors: 0,
|
|
767
|
+
},
|
|
768
|
+
|
|
769
|
+
entryKey(entry) {
|
|
770
|
+
const file = entry.file || this.currentFile || '';
|
|
771
|
+
const ts = entry.timestamp || '';
|
|
772
|
+
return `${file}:${entry.line_number || 0}:${ts}`;
|
|
773
|
+
},
|
|
774
|
+
|
|
775
|
+
currentFilterPayload() {
|
|
776
|
+
return {
|
|
777
|
+
query: this.searchQuery || null,
|
|
778
|
+
correlation: this.correlationFilter || null,
|
|
779
|
+
levels: this.selectedLevels || [],
|
|
780
|
+
threads: this.selectedThreads || [],
|
|
781
|
+
};
|
|
782
|
+
},
|
|
783
|
+
|
|
784
|
+
refreshLayout() {
|
|
785
|
+
this.measureViewports();
|
|
786
|
+
this.updateVisibleEntries(true);
|
|
787
|
+
this.updateVisibleThreads();
|
|
788
|
+
},
|
|
789
|
+
|
|
790
|
+
measureViewports() {
|
|
791
|
+
const logEl = document.getElementById('log-container');
|
|
792
|
+
const threadEl = document.getElementById('thread-list');
|
|
793
|
+
if (logEl) this.logViewportHeight = logEl.clientHeight || this.logViewportHeight;
|
|
794
|
+
if (threadEl) this.threadViewportHeight = threadEl.clientHeight || this.threadViewportHeight;
|
|
795
|
+
},
|
|
796
|
+
|
|
797
|
+
updateVisibleEntries(forceStick = false) {
|
|
798
|
+
const container = document.getElementById('log-container');
|
|
799
|
+
if (!container) return;
|
|
800
|
+
|
|
801
|
+
const total = this.filteredEntries.length;
|
|
802
|
+
if (total === 0) {
|
|
803
|
+
this.visibleEntries = [];
|
|
804
|
+
this.logTopSpacer = 0;
|
|
805
|
+
this.logBottomSpacer = 0;
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const viewport = this.logViewportHeight || container.clientHeight || 1;
|
|
810
|
+
const windowSize = Math.ceil(viewport / this.LOG_ROW_HEIGHT) + this.LOG_OVERSCAN * 2;
|
|
811
|
+
|
|
812
|
+
if (forceStick && this.autoScroll) {
|
|
813
|
+
const end = total;
|
|
814
|
+
const start = Math.max(0, end - windowSize);
|
|
815
|
+
this.logTopSpacer = start * this.LOG_ROW_HEIGHT;
|
|
816
|
+
const rendered = end - start;
|
|
817
|
+
this.visibleEntries = this.filteredEntries.slice(start, end);
|
|
818
|
+
this.logBottomSpacer = Math.max(
|
|
819
|
+
0,
|
|
820
|
+
total * this.LOG_ROW_HEIGHT - this.logTopSpacer - rendered * this.LOG_ROW_HEIGHT
|
|
821
|
+
);
|
|
822
|
+
this.ignoreScrollEvents = true;
|
|
823
|
+
container.scrollTop = container.scrollHeight;
|
|
824
|
+
requestAnimationFrame(() => {
|
|
825
|
+
const refreshed = document.getElementById('log-container');
|
|
826
|
+
if (refreshed) {
|
|
827
|
+
refreshed.scrollTop = refreshed.scrollHeight;
|
|
828
|
+
}
|
|
829
|
+
this.ignoreScrollEvents = false;
|
|
830
|
+
});
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
let scrollTop = container.scrollTop;
|
|
835
|
+
const start = Math.max(0, Math.floor(scrollTop / this.LOG_ROW_HEIGHT) - this.LOG_OVERSCAN);
|
|
836
|
+
const end = Math.min(total, start + windowSize);
|
|
837
|
+
|
|
838
|
+
this.logTopSpacer = start * this.LOG_ROW_HEIGHT;
|
|
839
|
+
const rendered = end - start;
|
|
840
|
+
this.visibleEntries = this.filteredEntries.slice(start, end);
|
|
841
|
+
this.logBottomSpacer = Math.max(
|
|
842
|
+
0,
|
|
843
|
+
total * this.LOG_ROW_HEIGHT - this.logTopSpacer - rendered * this.LOG_ROW_HEIGHT
|
|
844
|
+
);
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
updateVisibleThreads() {
|
|
848
|
+
const pool = this.filteredThreadPool();
|
|
849
|
+
const container = document.getElementById('thread-list');
|
|
850
|
+
const viewport = this.threadViewportHeight || (container ? container.clientHeight : 1);
|
|
851
|
+
const scrollTop = container ? container.scrollTop : 0;
|
|
852
|
+
|
|
853
|
+
if (pool.length === 0) {
|
|
854
|
+
this.visibleThreadsCache = [];
|
|
855
|
+
this.threadTopSpacer = 0;
|
|
856
|
+
this.threadBottomSpacer = 0;
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const start = Math.max(0, Math.floor(scrollTop / this.THREAD_ROW_HEIGHT) - this.THREAD_OVERSCAN);
|
|
861
|
+
const windowSize = Math.ceil(viewport / this.THREAD_ROW_HEIGHT) + this.THREAD_OVERSCAN * 2;
|
|
862
|
+
const end = Math.min(pool.length, start + windowSize);
|
|
863
|
+
|
|
864
|
+
this.threadTopSpacer = start * this.THREAD_ROW_HEIGHT;
|
|
865
|
+
const rendered = end - start;
|
|
866
|
+
this.visibleThreadsCache = pool.slice(start, end);
|
|
867
|
+
this.threadBottomSpacer = Math.max(
|
|
868
|
+
0,
|
|
869
|
+
pool.length * this.THREAD_ROW_HEIGHT - this.threadTopSpacer - rendered * this.THREAD_ROW_HEIGHT
|
|
870
|
+
);
|
|
871
|
+
},
|
|
872
|
+
|
|
873
|
+
async openSelected() {
|
|
874
|
+
if (this.selectedPaths.length === 0) return;
|
|
875
|
+
if (this.isFollowing && this.followSocket) {
|
|
876
|
+
this.followSocket.close();
|
|
877
|
+
}
|
|
878
|
+
this.loading = true;
|
|
879
|
+
const response = await fetch('/api/files/open_many', {
|
|
880
|
+
method: 'POST',
|
|
881
|
+
headers: {'Content-Type': 'application/json'},
|
|
882
|
+
body: JSON.stringify({paths: this.selectedPaths, filters: this.currentFilterPayload(), limit: this.MAX_ENTRIES})
|
|
883
|
+
});
|
|
884
|
+
const data = await response.json();
|
|
885
|
+
this.loading = false;
|
|
886
|
+
this.currentFile = null;
|
|
887
|
+
this.currentFiles = data.files || [];
|
|
888
|
+
this.interleaveCounts = data.file_counts || {};
|
|
889
|
+
this.interleaveMeta = data.file_meta || [];
|
|
890
|
+
this.activeFiles = Array.from(new Set([...this.activeFiles, ...this.currentFiles]));
|
|
891
|
+
this.interleaved = true;
|
|
892
|
+
this.entries = (data.entries || []).slice(-this.MAX_ENTRIES);
|
|
893
|
+
this.totalAvailable = data.total || this.entries.length;
|
|
894
|
+
this.showFilePicker = false;
|
|
895
|
+
this.selectedThreads = [];
|
|
896
|
+
this.threadSearch = '';
|
|
897
|
+
await this.applyFilters();
|
|
898
|
+
await this.loadThreads();
|
|
899
|
+
this.updateStats();
|
|
900
|
+
this.isFollowing = false;
|
|
901
|
+
this.websocketConnected = false;
|
|
902
|
+
if (this.autoScroll) {
|
|
903
|
+
this.$nextTick(() => this.scrollToBottom());
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
|
|
907
|
+
init() {
|
|
908
|
+
this.browseDirectory('.');
|
|
909
|
+
{% if active_files %}
|
|
910
|
+
this.activeFiles = {{ active_files | tojson }};
|
|
911
|
+
if (this.activeFiles.length > 0) {
|
|
912
|
+
this.openFile(this.activeFiles[0]);
|
|
913
|
+
}
|
|
914
|
+
{% endif %}
|
|
915
|
+
this.$nextTick(() => {
|
|
916
|
+
this.refreshLayout();
|
|
917
|
+
window.addEventListener('resize', () => this.refreshLayout());
|
|
918
|
+
});
|
|
919
|
+
},
|
|
920
|
+
|
|
921
|
+
async browseDirectory(dir) {
|
|
922
|
+
this.browseMode = 'browse';
|
|
923
|
+
this.globError = '';
|
|
924
|
+
this.globBaseDir = dir || '.';
|
|
925
|
+
const response = await fetch(`/api/files/browse?directory=${encodeURIComponent(dir)}`);
|
|
926
|
+
const data = await response.json();
|
|
927
|
+
if (!this.rootDir && data.log_root) {
|
|
928
|
+
this.rootDir = data.log_root;
|
|
929
|
+
}
|
|
930
|
+
this.currentDir = data.current_dir || dir;
|
|
931
|
+
this.parentDir = data.parent_dir;
|
|
932
|
+
this.files = data.files || [];
|
|
933
|
+
this.directories = data.directories || [];
|
|
934
|
+
},
|
|
935
|
+
|
|
936
|
+
toggleBrowseMode(mode) {
|
|
937
|
+
this.browseMode = mode;
|
|
938
|
+
if (mode === 'glob' && this.globResults.length === 0) {
|
|
939
|
+
this.globBaseDir = this.currentDir || '.';
|
|
940
|
+
this.globPattern = '**/*.log';
|
|
941
|
+
this.searchGlob();
|
|
942
|
+
}
|
|
943
|
+
},
|
|
944
|
+
|
|
945
|
+
async searchGlob() {
|
|
946
|
+
this.globLoading = true;
|
|
947
|
+
this.globError = '';
|
|
948
|
+
try {
|
|
949
|
+
const resp = await fetch(`/api/files/glob?pattern=${encodeURIComponent(this.globPattern)}&base_dir=${encodeURIComponent(this.globBaseDir)}`);
|
|
950
|
+
const data = await resp.json();
|
|
951
|
+
this.globResults = data.files || [];
|
|
952
|
+
this.globError = data.truncated ? `Showing first ${this.globResults.length} of ${data.count} matches` : '';
|
|
953
|
+
} catch (e) {
|
|
954
|
+
this.globError = 'Failed to search';
|
|
955
|
+
} finally {
|
|
956
|
+
this.globLoading = false;
|
|
957
|
+
}
|
|
958
|
+
},
|
|
959
|
+
|
|
960
|
+
async openFile(path) {
|
|
961
|
+
if (this.isFollowing && this.followSocket) {
|
|
962
|
+
this.followSocket.close();
|
|
963
|
+
}
|
|
964
|
+
this.loading = true;
|
|
965
|
+
this.partialLoad = false;
|
|
966
|
+
this.indexing = false;
|
|
967
|
+
this.skipFullIndex = true;
|
|
968
|
+
|
|
969
|
+
const quickPayload = {
|
|
970
|
+
path,
|
|
971
|
+
filters: this.currentFilterPayload(),
|
|
972
|
+
limit: this.MAX_ENTRIES,
|
|
973
|
+
quick: true,
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
try {
|
|
977
|
+
const quickResp = await fetch('/api/files/open', {
|
|
978
|
+
method: 'POST',
|
|
979
|
+
headers: {'Content-Type': 'application/json'},
|
|
980
|
+
body: JSON.stringify(quickPayload),
|
|
981
|
+
});
|
|
982
|
+
const quickData = await quickResp.json();
|
|
983
|
+
if (quickData.error) {
|
|
984
|
+
alert(quickData.error);
|
|
985
|
+
this.loading = false;
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
this.currentFile = quickData.file_path;
|
|
990
|
+
this.currentFiles = [quickData.file_path];
|
|
991
|
+
this.interleaved = false;
|
|
992
|
+
this.interleaveCounts = {};
|
|
993
|
+
this.interleaveMeta = [];
|
|
994
|
+
if (!this.activeFiles.includes(this.currentFile)) {
|
|
995
|
+
this.activeFiles.push(this.currentFile);
|
|
996
|
+
}
|
|
997
|
+
this.entries = (quickData.entries || []).slice(-this.MAX_ENTRIES);
|
|
998
|
+
this.totalAvailable = quickData.total || this.entries.length;
|
|
999
|
+
this.selectedThreads = [];
|
|
1000
|
+
this.threadSearch = '';
|
|
1001
|
+
this.partialLoad = !!quickData.partial;
|
|
1002
|
+
this.skipFullIndex = true;
|
|
1003
|
+
this.loading = false;
|
|
1004
|
+
await this.applyFilters();
|
|
1005
|
+
await this.loadThreads();
|
|
1006
|
+
this.updateStats();
|
|
1007
|
+
if (this.autoScroll) {
|
|
1008
|
+
this.$nextTick(() => this.scrollToBottom());
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Kick off full indexing in the background
|
|
1012
|
+
this.indexing = false;
|
|
1013
|
+
} catch (e) {
|
|
1014
|
+
console.error(e);
|
|
1015
|
+
alert('Failed to open file');
|
|
1016
|
+
this.loading = false;
|
|
1017
|
+
this.indexing = false;
|
|
1018
|
+
}
|
|
1019
|
+
},
|
|
1020
|
+
|
|
1021
|
+
async loadThreads() {
|
|
1022
|
+
const response = await fetch('/api/threads');
|
|
1023
|
+
this.threads = await response.json();
|
|
1024
|
+
this.$nextTick(() => this.updateVisibleThreads());
|
|
1025
|
+
},
|
|
1026
|
+
|
|
1027
|
+
async applyFilters() {
|
|
1028
|
+
if (this.partialLoad || this.indexing) {
|
|
1029
|
+
this.filteredEntries = this.entries;
|
|
1030
|
+
if (this.searchQuery) {
|
|
1031
|
+
const query = this.searchQuery.toLowerCase();
|
|
1032
|
+
this.filteredEntries = this.filteredEntries.filter(e => (e.message || '').toLowerCase().includes(query));
|
|
1033
|
+
}
|
|
1034
|
+
if (this.correlationFilter) {
|
|
1035
|
+
const cq = this.correlationFilter.toLowerCase();
|
|
1036
|
+
this.filteredEntries = this.filteredEntries.filter(e => (e.correlation_id || '').toLowerCase().includes(cq));
|
|
1037
|
+
}
|
|
1038
|
+
if (this.selectedLevels.length > 0) {
|
|
1039
|
+
this.filteredEntries = this.filteredEntries.filter(e => this.selectedLevels.includes(e.level));
|
|
1040
|
+
}
|
|
1041
|
+
if (this.selectedThreads.length > 0) {
|
|
1042
|
+
const set = new Set(this.selectedThreads);
|
|
1043
|
+
this.filteredEntries = this.filteredEntries.filter(e => set.has(e.thread_id));
|
|
1044
|
+
}
|
|
1045
|
+
this.$nextTick(() => {
|
|
1046
|
+
this.updateVisibleEntries(this.autoScroll);
|
|
1047
|
+
this.updateStats();
|
|
1048
|
+
});
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Try server-side filtering to avoid shipping unused rows
|
|
1053
|
+
if (this.currentFiles.length > 0 && !this.isFollowing) {
|
|
1054
|
+
const payload = {
|
|
1055
|
+
paths: this.currentFiles,
|
|
1056
|
+
filters: this.currentFilterPayload(),
|
|
1057
|
+
limit: this.MAX_ENTRIES,
|
|
1058
|
+
};
|
|
1059
|
+
try {
|
|
1060
|
+
const resp = await fetch('/api/files/filter', {
|
|
1061
|
+
method: 'POST',
|
|
1062
|
+
headers: {'Content-Type': 'application/json'},
|
|
1063
|
+
body: JSON.stringify(payload),
|
|
1064
|
+
});
|
|
1065
|
+
if (resp.ok) {
|
|
1066
|
+
const data = await resp.json();
|
|
1067
|
+
this.entries = (data.entries || []).slice(-this.MAX_ENTRIES);
|
|
1068
|
+
this.totalAvailable = data.total || this.entries.length;
|
|
1069
|
+
this.filteredEntries = this.entries;
|
|
1070
|
+
this.$nextTick(() => {
|
|
1071
|
+
this.updateVisibleEntries(this.autoScroll);
|
|
1072
|
+
this.updateStats();
|
|
1073
|
+
});
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
} catch (e) {
|
|
1077
|
+
console.warn('Server filter failed, falling back to client filter', e);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Client-side fallback
|
|
1082
|
+
let filtered = this.entries;
|
|
1083
|
+
|
|
1084
|
+
if (this.searchQuery) {
|
|
1085
|
+
const query = this.searchQuery.toLowerCase();
|
|
1086
|
+
filtered = filtered.filter(e => (e.message || '').toLowerCase().includes(query));
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (this.correlationFilter) {
|
|
1090
|
+
const cq = this.correlationFilter.toLowerCase();
|
|
1091
|
+
filtered = filtered.filter(e => (e.correlation_id || '').toLowerCase().includes(cq));
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (this.selectedLevels.length > 0) {
|
|
1095
|
+
filtered = filtered.filter(e => this.selectedLevels.includes(e.level));
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (this.selectedThreads.length > 0) {
|
|
1099
|
+
const set = new Set(this.selectedThreads);
|
|
1100
|
+
filtered = filtered.filter(e => set.has(e.thread_id));
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
this.filteredEntries = filtered;
|
|
1104
|
+
this.$nextTick(() => {
|
|
1105
|
+
this.updateVisibleEntries(this.autoScroll);
|
|
1106
|
+
this.updateStats();
|
|
1107
|
+
});
|
|
1108
|
+
},
|
|
1109
|
+
|
|
1110
|
+
updateStats() {
|
|
1111
|
+
this.stats.total = this.filteredEntries.length;
|
|
1112
|
+
this.stats.errors = this.filteredEntries.filter(e =>
|
|
1113
|
+
['ERROR', 'CRITICAL', 'FATAL'].includes(e.level)
|
|
1114
|
+
).length;
|
|
1115
|
+
},
|
|
1116
|
+
|
|
1117
|
+
toggleThread(threadId) {
|
|
1118
|
+
const idx = this.selectedThreads.indexOf(threadId);
|
|
1119
|
+
if (idx >= 0) {
|
|
1120
|
+
this.selectedThreads.splice(idx, 1);
|
|
1121
|
+
} else {
|
|
1122
|
+
this.selectedThreads.push(threadId);
|
|
1123
|
+
}
|
|
1124
|
+
this.applyFilters();
|
|
1125
|
+
},
|
|
1126
|
+
|
|
1127
|
+
removeThread(threadId) {
|
|
1128
|
+
const idx = this.selectedThreads.indexOf(threadId);
|
|
1129
|
+
if (idx >= 0) {
|
|
1130
|
+
this.selectedThreads.splice(idx, 1);
|
|
1131
|
+
this.applyFilters();
|
|
1132
|
+
}
|
|
1133
|
+
},
|
|
1134
|
+
|
|
1135
|
+
toggleThreadFromEntry(threadId) {
|
|
1136
|
+
if (!threadId) return;
|
|
1137
|
+
if (!this.selectedThreads.includes(threadId)) {
|
|
1138
|
+
this.selectedThreads.push(threadId);
|
|
1139
|
+
}
|
|
1140
|
+
this.applyFilters();
|
|
1141
|
+
},
|
|
1142
|
+
|
|
1143
|
+
clearThreadSelection() {
|
|
1144
|
+
this.selectedThreads = [];
|
|
1145
|
+
this.applyFilters();
|
|
1146
|
+
},
|
|
1147
|
+
|
|
1148
|
+
async loadFullCurrent() {
|
|
1149
|
+
if (!this.currentFile) return;
|
|
1150
|
+
this.indexing = true;
|
|
1151
|
+
this.partialLoad = false;
|
|
1152
|
+
this.skipFullIndex = false;
|
|
1153
|
+
try {
|
|
1154
|
+
const response = await fetch('/api/files/open', {
|
|
1155
|
+
method: 'POST',
|
|
1156
|
+
headers: {'Content-Type': 'application/json'},
|
|
1157
|
+
body: JSON.stringify({
|
|
1158
|
+
path: this.currentFile,
|
|
1159
|
+
filters: this.currentFilterPayload(),
|
|
1160
|
+
limit: this.MAX_ENTRIES,
|
|
1161
|
+
quick: false,
|
|
1162
|
+
}),
|
|
1163
|
+
});
|
|
1164
|
+
const data = await response.json();
|
|
1165
|
+
if (data.error) {
|
|
1166
|
+
alert(data.error);
|
|
1167
|
+
this.indexing = false;
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
this.entries = (data.entries || []).slice(-this.MAX_ENTRIES);
|
|
1171
|
+
this.totalAvailable = data.total || this.entries.length;
|
|
1172
|
+
this.selectedThreads = [];
|
|
1173
|
+
this.threadSearch = '';
|
|
1174
|
+
this.partialLoad = false;
|
|
1175
|
+
this.applyFilters();
|
|
1176
|
+
this.loadThreads();
|
|
1177
|
+
this.updateStats();
|
|
1178
|
+
if (this.autoScroll) {
|
|
1179
|
+
this.$nextTick(() => this.scrollToBottom());
|
|
1180
|
+
}
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
console.error(e);
|
|
1183
|
+
alert('Failed to load full log');
|
|
1184
|
+
} finally {
|
|
1185
|
+
this.indexing = false;
|
|
1186
|
+
}
|
|
1187
|
+
},
|
|
1188
|
+
|
|
1189
|
+
filteredThreadPool() {
|
|
1190
|
+
const query = this.threadSearch.toLowerCase().trim();
|
|
1191
|
+
if (!query) return this.threads;
|
|
1192
|
+
return this.threads.filter(t => (t.thread_id || '').toLowerCase().includes(query));
|
|
1193
|
+
},
|
|
1194
|
+
|
|
1195
|
+
breadcrumbs() {
|
|
1196
|
+
const root = this.rootDir || '';
|
|
1197
|
+
const path = this.currentDir || '';
|
|
1198
|
+
if (!root || !path || !path.startsWith(root)) {
|
|
1199
|
+
return [];
|
|
1200
|
+
}
|
|
1201
|
+
const parts = [];
|
|
1202
|
+
parts.push({label: root.split('/').pop() || root, path: root});
|
|
1203
|
+
const relative = path.slice(root.length).replace(/^\\/+/, '');
|
|
1204
|
+
let acc = root;
|
|
1205
|
+
relative.split('/').filter(Boolean).forEach(segment => {
|
|
1206
|
+
acc = acc.endsWith('/') ? acc + segment : acc + '/' + segment;
|
|
1207
|
+
parts.push({label: segment, path: acc});
|
|
1208
|
+
});
|
|
1209
|
+
return parts;
|
|
1210
|
+
},
|
|
1211
|
+
|
|
1212
|
+
selectFile(file) {
|
|
1213
|
+
this.openFile(file);
|
|
1214
|
+
},
|
|
1215
|
+
|
|
1216
|
+
toggleFollow() {
|
|
1217
|
+
if (this.isFollowing) {
|
|
1218
|
+
if (this.followSocket) {
|
|
1219
|
+
this.followSocket.close();
|
|
1220
|
+
}
|
|
1221
|
+
this.isFollowing = false;
|
|
1222
|
+
this.websocketConnected = false;
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
if (!this.currentFile || this.interleaved) return;
|
|
1227
|
+
|
|
1228
|
+
this.followSocket = new WebSocket(`ws://${location.host}/ws`);
|
|
1229
|
+
this.followSocket.onopen = () => {
|
|
1230
|
+
this.websocketConnected = true;
|
|
1231
|
+
this.isFollowing = true;
|
|
1232
|
+
this.autoScroll = true;
|
|
1233
|
+
this.followSocket.send(JSON.stringify({
|
|
1234
|
+
action: 'follow',
|
|
1235
|
+
file_path: this.currentFile,
|
|
1236
|
+
filters: this.currentFilterPayload(),
|
|
1237
|
+
}));
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
this.followSocket.onmessage = (event) => {
|
|
1241
|
+
const data = JSON.parse(event.data);
|
|
1242
|
+
if (data.type === 'log_entry') {
|
|
1243
|
+
this.entries.push(data.entry);
|
|
1244
|
+
if (this.entries.length > this.MAX_ENTRIES) {
|
|
1245
|
+
this.entries = this.entries.slice(-this.MAX_ENTRIES);
|
|
1246
|
+
}
|
|
1247
|
+
this.totalAvailable += 1;
|
|
1248
|
+
this.applyFilters();
|
|
1249
|
+
if (this.autoScroll) {
|
|
1250
|
+
this.$nextTick(() => this.scrollToBottom());
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
this.followSocket.onclose = () => {
|
|
1256
|
+
this.websocketConnected = false;
|
|
1257
|
+
this.isFollowing = false;
|
|
1258
|
+
};
|
|
1259
|
+
},
|
|
1260
|
+
|
|
1261
|
+
formatTimestamp(ts) {
|
|
1262
|
+
if (!ts) return 'N/A';
|
|
1263
|
+
return new Date(ts).toLocaleString();
|
|
1264
|
+
},
|
|
1265
|
+
|
|
1266
|
+
formatSize(bytes) {
|
|
1267
|
+
if (bytes < 1024) return bytes + ' B';
|
|
1268
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
1269
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
1270
|
+
},
|
|
1271
|
+
|
|
1272
|
+
scrollToBottom() {
|
|
1273
|
+
const container = document.getElementById('log-container');
|
|
1274
|
+
if (!container) return;
|
|
1275
|
+
this.ignoreScrollEvents = true;
|
|
1276
|
+
container.scrollTop = container.scrollHeight;
|
|
1277
|
+
requestAnimationFrame(() => {
|
|
1278
|
+
this.ignoreScrollEvents = false;
|
|
1279
|
+
this.updateVisibleEntries(true);
|
|
1280
|
+
});
|
|
1281
|
+
},
|
|
1282
|
+
|
|
1283
|
+
handleScroll() {
|
|
1284
|
+
if (this.ignoreScrollEvents) return;
|
|
1285
|
+
if (this.logScrollScheduled) return;
|
|
1286
|
+
this.logScrollScheduled = true;
|
|
1287
|
+
requestAnimationFrame(() => {
|
|
1288
|
+
const container = document.getElementById('log-container');
|
|
1289
|
+
if (container) {
|
|
1290
|
+
const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight);
|
|
1291
|
+
const isAtBottom = distanceFromBottom <= 120;
|
|
1292
|
+
if (!isAtBottom) {
|
|
1293
|
+
this.autoScroll = false;
|
|
1294
|
+
} else {
|
|
1295
|
+
this.autoScroll = true;
|
|
1296
|
+
}
|
|
1297
|
+
this.updateVisibleEntries(this.autoScroll);
|
|
1298
|
+
}
|
|
1299
|
+
this.logScrollScheduled = false;
|
|
1300
|
+
});
|
|
1301
|
+
},
|
|
1302
|
+
|
|
1303
|
+
// ===== Hierarchy Functions =====
|
|
1304
|
+
|
|
1305
|
+
async loadHierarchy() {
|
|
1306
|
+
if (!this.hierarchyRootId || this.currentFiles.length === 0) {
|
|
1307
|
+
if (this.hierarchyRootId && this.activeFiles.length > 0) {
|
|
1308
|
+
// Use active files if no current files
|
|
1309
|
+
this.currentFiles = this.activeFiles;
|
|
1310
|
+
} else {
|
|
1311
|
+
this.hierarchyError = 'No files loaded. Open a log file first.';
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
this.hierarchyLoading = true;
|
|
1317
|
+
this.hierarchyError = '';
|
|
1318
|
+
this.hierarchy = null;
|
|
1319
|
+
this.errorAnalysis = null;
|
|
1320
|
+
|
|
1321
|
+
try {
|
|
1322
|
+
const response = await fetch('/api/hierarchy', {
|
|
1323
|
+
method: 'POST',
|
|
1324
|
+
headers: {'Content-Type': 'application/json'},
|
|
1325
|
+
body: JSON.stringify({
|
|
1326
|
+
paths: this.currentFiles.length > 0 ? this.currentFiles : this.activeFiles,
|
|
1327
|
+
root_identifier: this.hierarchyRootId,
|
|
1328
|
+
min_confidence: 0.0,
|
|
1329
|
+
use_naming_patterns: true,
|
|
1330
|
+
use_temporal_inference: true,
|
|
1331
|
+
})
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
const data = await response.json();
|
|
1335
|
+
|
|
1336
|
+
if (data.error) {
|
|
1337
|
+
this.hierarchyError = data.error;
|
|
1338
|
+
} else {
|
|
1339
|
+
this.hierarchy = data.hierarchy;
|
|
1340
|
+
this.errorAnalysis = data.error_analysis;
|
|
1341
|
+
this.calculateTimeRange();
|
|
1342
|
+
}
|
|
1343
|
+
} catch (e) {
|
|
1344
|
+
this.hierarchyError = 'Failed to load hierarchy: ' + e.message;
|
|
1345
|
+
} finally {
|
|
1346
|
+
this.hierarchyLoading = false;
|
|
1347
|
+
}
|
|
1348
|
+
},
|
|
1349
|
+
|
|
1350
|
+
calculateTimeRange() {
|
|
1351
|
+
if (!this.hierarchy?.roots) return;
|
|
1352
|
+
|
|
1353
|
+
let minTime = null;
|
|
1354
|
+
let maxTime = null;
|
|
1355
|
+
|
|
1356
|
+
const processNode = (node) => {
|
|
1357
|
+
if (node.start_time) {
|
|
1358
|
+
const t = new Date(node.start_time).getTime();
|
|
1359
|
+
if (minTime === null || t < minTime) minTime = t;
|
|
1360
|
+
}
|
|
1361
|
+
if (node.end_time) {
|
|
1362
|
+
const t = new Date(node.end_time).getTime();
|
|
1363
|
+
if (maxTime === null || t > maxTime) maxTime = t;
|
|
1364
|
+
}
|
|
1365
|
+
(node.children || []).forEach(processNode);
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
this.hierarchy.roots.forEach(processNode);
|
|
1369
|
+
this.hierarchyMinTime = minTime;
|
|
1370
|
+
this.hierarchyMaxTime = maxTime;
|
|
1371
|
+
},
|
|
1372
|
+
|
|
1373
|
+
formatDuration(ms) {
|
|
1374
|
+
if (ms === null || ms === undefined) return 'N/A';
|
|
1375
|
+
if (ms < 1) return '<1ms';
|
|
1376
|
+
if (ms < 1000) return ms.toFixed(0) + 'ms';
|
|
1377
|
+
if (ms < 60000) return (ms / 1000).toFixed(2) + 's';
|
|
1378
|
+
return (ms / 60000).toFixed(2) + 'm';
|
|
1379
|
+
},
|
|
1380
|
+
|
|
1381
|
+
renderHierarchyNode(node, depth) {
|
|
1382
|
+
const hasChildren = node.children && node.children.length > 0;
|
|
1383
|
+
const icon = node.error_count > 0 ? '❌' : (hasChildren ? '📁' : '📄');
|
|
1384
|
+
const bgClass = node.error_count > 0 ? 'bg-red-900/20' : '';
|
|
1385
|
+
const indent = depth * 16;
|
|
1386
|
+
|
|
1387
|
+
let html = `
|
|
1388
|
+
<div x-data="{ expanded: ${depth < 3} }" style="margin-left: ${indent}px">
|
|
1389
|
+
<div class="flex items-center gap-2 p-1 hover:bg-white/5 rounded cursor-pointer ${bgClass}"
|
|
1390
|
+
@click="expanded = !expanded">
|
|
1391
|
+
<span x-text="expanded ? '▼' : '▶'" class="text-gray-500 w-4 ${hasChildren ? '' : 'invisible'}"></span>
|
|
1392
|
+
<span>${icon}</span>
|
|
1393
|
+
<span class="text-blue-300">${this.escapeHtml(node.id)}</span>
|
|
1394
|
+
<span class="text-gray-500">(${node.entry_count || 0} entries)</span>
|
|
1395
|
+
${node.duration_ms ? `<span class="text-yellow-400">${this.formatDuration(node.duration_ms)}</span>` : ''}
|
|
1396
|
+
<span class="text-gray-600 text-xs">${node.node_type || ''}</span>
|
|
1397
|
+
</div>
|
|
1398
|
+
`;
|
|
1399
|
+
|
|
1400
|
+
if (hasChildren) {
|
|
1401
|
+
html += `<div x-show="expanded" class="border-l border-gray-700">`;
|
|
1402
|
+
for (const child of node.children) {
|
|
1403
|
+
html += this.renderHierarchyNode(child, depth + 1);
|
|
1404
|
+
}
|
|
1405
|
+
html += `</div>`;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
html += `</div>`;
|
|
1409
|
+
return html;
|
|
1410
|
+
},
|
|
1411
|
+
|
|
1412
|
+
escapeHtml(str) {
|
|
1413
|
+
if (!str) return '';
|
|
1414
|
+
return str.replace(/&/g, '&')
|
|
1415
|
+
.replace(/</g, '<')
|
|
1416
|
+
.replace(/>/g, '>')
|
|
1417
|
+
.replace(/"/g, '"');
|
|
1418
|
+
},
|
|
1419
|
+
|
|
1420
|
+
flattenHierarchy() {
|
|
1421
|
+
if (!this.hierarchy?.roots) return [];
|
|
1422
|
+
|
|
1423
|
+
const result = [];
|
|
1424
|
+
const flatten = (node, depth) => {
|
|
1425
|
+
result.push({ ...node, depth });
|
|
1426
|
+
(node.children || []).forEach(child => flatten(child, depth + 1));
|
|
1427
|
+
};
|
|
1428
|
+
|
|
1429
|
+
this.hierarchy.roots.forEach(root => flatten(root, 0));
|
|
1430
|
+
return result;
|
|
1431
|
+
},
|
|
1432
|
+
|
|
1433
|
+
getWaterfallBarLeft(node) {
|
|
1434
|
+
if (!node.start_time || !this.hierarchyMinTime || !this.hierarchyMaxTime) return 0;
|
|
1435
|
+
const start = new Date(node.start_time).getTime();
|
|
1436
|
+
const range = this.hierarchyMaxTime - this.hierarchyMinTime;
|
|
1437
|
+
if (range <= 0) return 0;
|
|
1438
|
+
return ((start - this.hierarchyMinTime) / range) * 100;
|
|
1439
|
+
},
|
|
1440
|
+
|
|
1441
|
+
getWaterfallBarWidth(node) {
|
|
1442
|
+
if (!node.duration_ms || !this.hierarchyMaxTime || !this.hierarchyMinTime) {
|
|
1443
|
+
return 5; // Minimum width for visibility
|
|
1444
|
+
}
|
|
1445
|
+
const range = this.hierarchyMaxTime - this.hierarchyMinTime;
|
|
1446
|
+
if (range <= 0) return 5;
|
|
1447
|
+
const width = (node.duration_ms / range) * 100;
|
|
1448
|
+
return Math.max(2, Math.min(100, width)); // Min 2%, max 100%
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
</script>
|
|
1453
|
+
</body>
|
|
1454
|
+
</html>
|