@sienklogic/plan-build-run 2.30.0 → 2.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dashboard/public/css/settings.css +314 -0
  3. package/dashboard/public/css/timeline.css +240 -0
  4. package/dashboard/src/components/Layout.tsx +4 -0
  5. package/dashboard/src/components/settings/ConfigEditor.tsx +399 -0
  6. package/dashboard/src/components/settings/LogEntryList.tsx +108 -0
  7. package/dashboard/src/components/settings/LogFileList.tsx +36 -0
  8. package/dashboard/src/components/settings/LogViewer.tsx +129 -0
  9. package/dashboard/src/components/settings/SettingsPage.tsx +44 -0
  10. package/dashboard/src/components/timeline/AnalyticsPanel.tsx +99 -0
  11. package/dashboard/src/components/timeline/DependencyGraph.tsx +23 -0
  12. package/dashboard/src/components/timeline/TimelinePage.tsx +124 -0
  13. package/dashboard/src/index.tsx +4 -0
  14. package/dashboard/src/routes/settings.routes.tsx +231 -0
  15. package/dashboard/src/routes/timeline.routes.tsx +50 -0
  16. package/dashboard/src/services/analytics.service.d.ts +24 -0
  17. package/dashboard/src/services/config.service.d.ts +9 -0
  18. package/dashboard/src/services/local-llm-metrics.service.d.ts +26 -0
  19. package/dashboard/src/services/log.service.d.ts +13 -0
  20. package/dashboard/src/services/timeline.service.d.ts +20 -0
  21. package/dashboard/src/services/timeline.service.js +174 -0
  22. package/package.json +1 -1
  23. package/plugins/copilot-pbr/plugin.json +1 -1
  24. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  25. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  26. package/plugins/pbr/scripts/config-schema.json +12 -0
  27. package/plugins/pbr/scripts/context-budget-check.js +4 -1
  28. package/plugins/pbr/scripts/enforce-pbr-workflow.js +218 -0
  29. package/plugins/pbr/scripts/pre-bash-dispatch.js +7 -0
  30. package/plugins/pbr/scripts/pre-write-dispatch.js +30 -18
  31. package/plugins/pbr/scripts/progress-tracker.js +1 -1
  32. package/plugins/pbr/scripts/validate-task.js +6 -1
@@ -0,0 +1,399 @@
1
+ interface ConfigEditorProps {
2
+ config: Record<string, unknown>;
3
+ }
4
+
5
+ function BoolField({ name, label, checked }: { name: string; label: string; checked: boolean }) {
6
+ return (
7
+ <label class="config-field config-field--check">
8
+ <input type="checkbox" name={name} checked={checked || false} />
9
+ {label}
10
+ </label>
11
+ );
12
+ }
13
+
14
+ function SelectField({
15
+ name,
16
+ label,
17
+ value,
18
+ options,
19
+ }: {
20
+ name: string;
21
+ label: string;
22
+ value: string;
23
+ options: string[];
24
+ }) {
25
+ return (
26
+ <label class="config-field">
27
+ <span class="config-field__label">{label}</span>
28
+ <select name={name}>
29
+ {options.map((o) => (
30
+ <option value={o} selected={o === value}>
31
+ {o}
32
+ </option>
33
+ ))}
34
+ </select>
35
+ </label>
36
+ );
37
+ }
38
+
39
+ function TextField({
40
+ name,
41
+ label,
42
+ value,
43
+ type,
44
+ readonly,
45
+ }: {
46
+ name: string;
47
+ label: string;
48
+ value: string | number;
49
+ type?: string;
50
+ readonly?: boolean;
51
+ }) {
52
+ return (
53
+ <label class="config-field">
54
+ <span class="config-field__label">{label}</span>
55
+ <input
56
+ type={type || 'text'}
57
+ name={name}
58
+ value={String(value ?? '')}
59
+ readonly={readonly}
60
+ />
61
+ </label>
62
+ );
63
+ }
64
+
65
+ function SectionTitle({ title }: { title: string }) {
66
+ return <h3 class="config-section__title">{title}</h3>;
67
+ }
68
+
69
+ export function ConfigEditor({ config }: ConfigEditorProps) {
70
+ const features = (config.features as Record<string, boolean>) || {};
71
+ const gates = (config.gates as Record<string, boolean>) || {};
72
+ const models = (config.models as Record<string, string>) || {};
73
+ const para = (config.parallelization as Record<string, unknown>) || {};
74
+ const planning = (config.planning as Record<string, unknown>) || {};
75
+ const git = (config.git as Record<string, string>) || {};
76
+ const safety = (config.safety as Record<string, boolean>) || {};
77
+ const localLlm = (config.local_llm as Record<string, unknown>) || {};
78
+ const llmFeatures = (localLlm.features as Record<string, boolean>) || {};
79
+ const llmMetrics = (localLlm.metrics as Record<string, unknown>) || {};
80
+ const llmAdvanced = (localLlm.advanced as Record<string, unknown>) || {};
81
+
82
+ const rawJson = JSON.stringify(config, null, 2);
83
+
84
+ return (
85
+ <div class="config-editor" x-data="{ mode: 'form' }">
86
+ {/* Mode toggle */}
87
+ <div class="config-mode-toggle" role="group" aria-label="Editor mode">
88
+ <button
89
+ type="button"
90
+ class="tab-btn"
91
+ x-on:click="mode = 'form'"
92
+ x-bind:class="{ active: mode === 'form' }"
93
+ >
94
+ Form
95
+ </button>
96
+ <button
97
+ type="button"
98
+ class="tab-btn"
99
+ x-on:click="mode = 'raw'"
100
+ x-bind:class="{ active: mode === 'raw' }"
101
+ >
102
+ Raw JSON
103
+ </button>
104
+ </div>
105
+
106
+ {/* Form mode */}
107
+ <div x-show="mode === 'form'">
108
+ <form
109
+ hx-post="/api/settings/config"
110
+ hx-target="#config-feedback"
111
+ hx-swap="innerHTML"
112
+ hx-encoding="application/x-www-form-urlencoded"
113
+ >
114
+ {/* General section */}
115
+ <div class="config-section">
116
+ <SectionTitle title="General" />
117
+ <SelectField
118
+ name="mode"
119
+ label="Mode"
120
+ value={String(config.mode ?? 'normal')}
121
+ options={['normal', 'autonomous', 'cautious']}
122
+ />
123
+ <SelectField
124
+ name="depth"
125
+ label="Depth"
126
+ value={String(config.depth ?? 'standard')}
127
+ options={['standard', 'comprehensive', 'light']}
128
+ />
129
+ <SelectField
130
+ name="context_strategy"
131
+ label="Context Strategy"
132
+ value={String(config.context_strategy ?? 'aggressive')}
133
+ options={['aggressive', 'balanced', 'conservative']}
134
+ />
135
+ <TextField
136
+ name="version"
137
+ label="Version"
138
+ value={String(config.version ?? '')}
139
+ readonly={true}
140
+ />
141
+ </div>
142
+
143
+ {/* Features section */}
144
+ <div class="config-section">
145
+ <SectionTitle title="Features" />
146
+ {Object.entries(features).map(([key, val]) => (
147
+ <BoolField name={`features.${key}`} label={key.replace(/_/g, ' ')} checked={val} />
148
+ ))}
149
+ </div>
150
+
151
+ {/* Gates section */}
152
+ <div class="config-section">
153
+ <SectionTitle title="Gates" />
154
+ {Object.entries(gates).map(([key, val]) => (
155
+ <BoolField name={`gates.${key}`} label={key.replace(/_/g, ' ')} checked={val} />
156
+ ))}
157
+ </div>
158
+
159
+ {/* Models section */}
160
+ <div class="config-section">
161
+ <SectionTitle title="Models" />
162
+ {Object.entries(models).map(([key, val]) => (
163
+ <SelectField
164
+ name={`models.${key}`}
165
+ label={key}
166
+ value={val}
167
+ options={['haiku', 'sonnet', 'opus', 'inherit']}
168
+ />
169
+ ))}
170
+ </div>
171
+
172
+ {/* Parallelization section */}
173
+ <div class="config-section">
174
+ <SectionTitle title="Parallelization" />
175
+ <BoolField name="parallelization.enabled" label="Enabled" checked={!!para.enabled} />
176
+ <BoolField name="parallelization.plan_level" label="Plan Level" checked={!!para.plan_level} />
177
+ <BoolField name="parallelization.task_level" label="Task Level" checked={!!para.task_level} />
178
+ <BoolField name="parallelization.use_teams" label="Use Teams" checked={!!para.use_teams} />
179
+ <TextField
180
+ name="parallelization.max_concurrent_agents"
181
+ label="Max Concurrent Agents"
182
+ value={Number(para.max_concurrent_agents ?? 3)}
183
+ type="number"
184
+ />
185
+ <TextField
186
+ name="parallelization.min_plans_for_parallel"
187
+ label="Min Plans for Parallel"
188
+ value={Number(para.min_plans_for_parallel ?? 2)}
189
+ type="number"
190
+ />
191
+ </div>
192
+
193
+ {/* Planning section */}
194
+ <div class="config-section">
195
+ <SectionTitle title="Planning" />
196
+ <BoolField
197
+ name="planning.commit_docs"
198
+ label="Commit Docs"
199
+ checked={!!planning.commit_docs}
200
+ />
201
+ <TextField
202
+ name="planning.max_tasks_per_plan"
203
+ label="Max Tasks per Plan"
204
+ value={Number(planning.max_tasks_per_plan ?? 8)}
205
+ type="number"
206
+ />
207
+ <BoolField
208
+ name="planning.search_gitignored"
209
+ label="Search Gitignored"
210
+ checked={!!planning.search_gitignored}
211
+ />
212
+ </div>
213
+
214
+ {/* Git section */}
215
+ <div class="config-section">
216
+ <SectionTitle title="Git" />
217
+ <SelectField
218
+ name="git.mode"
219
+ label="Mode"
220
+ value={git.mode ?? 'enabled'}
221
+ options={['enabled', 'disabled']}
222
+ />
223
+ <SelectField
224
+ name="git.branching"
225
+ label="Branching"
226
+ value={git.branching ?? 'none'}
227
+ options={['none', 'phase', 'milestone']}
228
+ />
229
+ <TextField
230
+ name="git.commit_format"
231
+ label="Commit Format"
232
+ value={git.commit_format ?? ''}
233
+ />
234
+ <TextField
235
+ name="git.phase_branch_template"
236
+ label="Phase Branch Template"
237
+ value={git.phase_branch_template ?? ''}
238
+ />
239
+ <TextField
240
+ name="git.milestone_branch_template"
241
+ label="Milestone Branch Template"
242
+ value={git.milestone_branch_template ?? ''}
243
+ />
244
+ </div>
245
+
246
+ {/* Safety section */}
247
+ <div class="config-section">
248
+ <SectionTitle title="Safety" />
249
+ <BoolField
250
+ name="safety.always_confirm_destructive"
251
+ label="Always Confirm Destructive"
252
+ checked={!!safety.always_confirm_destructive}
253
+ />
254
+ <BoolField
255
+ name="safety.always_confirm_external_services"
256
+ label="Always Confirm External Services"
257
+ checked={!!safety.always_confirm_external_services}
258
+ />
259
+ </div>
260
+
261
+ {/* Local LLM section */}
262
+ <div class="config-section">
263
+ <SectionTitle title="Local LLM" />
264
+ <BoolField name="local_llm.enabled" label="Enabled" checked={!!localLlm.enabled} />
265
+ <TextField name="local_llm.provider" label="Provider" value={String(localLlm.provider ?? '')} />
266
+ <TextField name="local_llm.endpoint" label="Endpoint" value={String(localLlm.endpoint ?? '')} />
267
+ <TextField name="local_llm.model" label="Model" value={String(localLlm.model ?? '')} />
268
+ <TextField
269
+ name="local_llm.timeout_ms"
270
+ label="Timeout (ms)"
271
+ value={Number(localLlm.timeout_ms ?? 30000)}
272
+ type="number"
273
+ />
274
+ <TextField
275
+ name="local_llm.max_retries"
276
+ label="Max Retries"
277
+ value={Number(localLlm.max_retries ?? 3)}
278
+ type="number"
279
+ />
280
+ <SelectField
281
+ name="local_llm.fallback"
282
+ label="Fallback"
283
+ value={String(localLlm.fallback ?? 'frontier')}
284
+ options={['frontier', 'skip', 'error']}
285
+ />
286
+ <SelectField
287
+ name="local_llm.routing_strategy"
288
+ label="Routing Strategy"
289
+ value={String(localLlm.routing_strategy ?? 'local_first')}
290
+ options={['local_first', 'frontier_first', 'always_local', 'always_frontier']}
291
+ />
292
+
293
+ {/* LLM Features sub-section */}
294
+ <div class="config-section config-section--nested">
295
+ <SectionTitle title="LLM Features" />
296
+ {Object.entries(llmFeatures).map(([key, val]) => (
297
+ <BoolField
298
+ name={`local_llm.features.${key}`}
299
+ label={key.replace(/_/g, ' ')}
300
+ checked={val}
301
+ />
302
+ ))}
303
+ {Object.keys(llmFeatures).length === 0 && (
304
+ <p class="config-empty">No local LLM features configured.</p>
305
+ )}
306
+ </div>
307
+
308
+ {/* LLM Metrics sub-section */}
309
+ <div class="config-section config-section--nested">
310
+ <SectionTitle title="LLM Metrics" />
311
+ <BoolField
312
+ name="local_llm.metrics.enabled"
313
+ label="Enabled"
314
+ checked={!!llmMetrics.enabled}
315
+ />
316
+ <BoolField
317
+ name="local_llm.metrics.show_session_summary"
318
+ label="Show Session Summary"
319
+ checked={!!llmMetrics.show_session_summary}
320
+ />
321
+ <TextField
322
+ name="local_llm.metrics.log_file"
323
+ label="Log File"
324
+ value={String(llmMetrics.log_file ?? '')}
325
+ />
326
+ <TextField
327
+ name="local_llm.metrics.frontier_token_rate"
328
+ label="Frontier Token Rate"
329
+ value={Number(llmMetrics.frontier_token_rate ?? 0)}
330
+ type="number"
331
+ />
332
+ </div>
333
+
334
+ {/* LLM Advanced sub-section */}
335
+ <div class="config-section config-section--nested">
336
+ <SectionTitle title="LLM Advanced" />
337
+ <TextField
338
+ name="local_llm.advanced.confidence_threshold"
339
+ label="Confidence Threshold"
340
+ value={Number(llmAdvanced.confidence_threshold ?? 0)}
341
+ type="number"
342
+ />
343
+ <TextField
344
+ name="local_llm.advanced.max_input_tokens"
345
+ label="Max Input Tokens"
346
+ value={Number(llmAdvanced.max_input_tokens ?? 0)}
347
+ type="number"
348
+ />
349
+ <TextField
350
+ name="local_llm.advanced.num_ctx"
351
+ label="Num Ctx"
352
+ value={Number(llmAdvanced.num_ctx ?? 0)}
353
+ type="number"
354
+ />
355
+ <TextField
356
+ name="local_llm.advanced.disable_after_failures"
357
+ label="Disable After Failures"
358
+ value={Number(llmAdvanced.disable_after_failures ?? 0)}
359
+ type="number"
360
+ />
361
+ <TextField
362
+ name="local_llm.advanced.keep_alive"
363
+ label="Keep Alive"
364
+ value={String(llmAdvanced.keep_alive ?? '')}
365
+ />
366
+ <BoolField
367
+ name="local_llm.advanced.shadow_mode"
368
+ label="Shadow Mode"
369
+ checked={!!llmAdvanced.shadow_mode}
370
+ />
371
+ </div>
372
+ </div>
373
+
374
+ <div class="config-actions">
375
+ <button type="submit" class="btn btn--primary">Save Config</button>
376
+ </div>
377
+ </form>
378
+ <div id="config-feedback" class="config-feedback" aria-live="polite"></div>
379
+ </div>
380
+
381
+ {/* Raw JSON mode */}
382
+ <div x-show="mode === 'raw'" x-cloak>
383
+ <form
384
+ hx-post="/api/settings/config"
385
+ hx-target="#config-feedback-raw"
386
+ hx-swap="innerHTML"
387
+ >
388
+ <textarea name="rawJson" rows={30} class="config-raw-json">
389
+ {rawJson}
390
+ </textarea>
391
+ <div class="config-actions">
392
+ <button type="submit" class="btn btn--primary">Save JSON</button>
393
+ </div>
394
+ </form>
395
+ <div id="config-feedback-raw" class="config-feedback" aria-live="polite"></div>
396
+ </div>
397
+ </div>
398
+ );
399
+ }
@@ -0,0 +1,108 @@
1
+ interface LogEntryListProps {
2
+ entries: object[];
3
+ total: number;
4
+ page: number;
5
+ pageSize: number;
6
+ file: string;
7
+ typeFilter: string;
8
+ q: string;
9
+ }
10
+
11
+ function sanitizeType(t: unknown): string {
12
+ if (typeof t !== 'string' || !t) return 'unknown';
13
+ return t.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
14
+ }
15
+
16
+ function getTimestamp(entry: Record<string, unknown>): string {
17
+ const raw = entry['timestamp'] ?? entry['ts'] ?? entry['time'];
18
+ if (!raw) return '—';
19
+ try {
20
+ return new Date(raw as string).toLocaleString();
21
+ } catch {
22
+ return String(raw);
23
+ }
24
+ }
25
+
26
+ function getSummary(entry: Record<string, unknown>): string {
27
+ const { type: _type, timestamp: _ts, ts: _ts2, time: _time, ...rest } = entry;
28
+ const str = JSON.stringify(rest);
29
+ return str.length > 120 ? str.slice(0, 117) + '...' : str;
30
+ }
31
+
32
+ export function LogEntryRow({ entry }: { entry: object }) {
33
+ const e = entry as Record<string, unknown>;
34
+ const type = typeof e['type'] === 'string' ? e['type'] : '';
35
+ const badge = sanitizeType(type);
36
+ const timestamp = getTimestamp(e);
37
+ const summary = getSummary(e);
38
+
39
+ return (
40
+ <tr>
41
+ <td>
42
+ <span class={`log-badge log-badge--${badge}`}>{type || '—'}</span>
43
+ </td>
44
+ <td>{timestamp}</td>
45
+ <td>{summary}</td>
46
+ </tr>
47
+ );
48
+ }
49
+
50
+ export function LogEntryList({ entries, total, page, pageSize, file, typeFilter, q }: LogEntryListProps) {
51
+ const start = (page - 1) * pageSize + 1;
52
+ const end = Math.min(page * pageSize, total);
53
+ const encodedFile = encodeURIComponent(file);
54
+ const encodedQ = encodeURIComponent(q);
55
+ const encodedType = encodeURIComponent(typeFilter);
56
+
57
+ const baseQuery = `file=${encodedFile}&typeFilter=${encodedType}&q=${encodedQ}`;
58
+
59
+ return (
60
+ <div id="log-entries">
61
+ <p class="log-summary">
62
+ {total === 0
63
+ ? 'No entries found.'
64
+ : `Showing ${start}–${end} of ${total} entries`}
65
+ </p>
66
+
67
+ {entries.length > 0 && (
68
+ <table class="log-table">
69
+ <thead>
70
+ <tr>
71
+ <th>Type</th>
72
+ <th>Timestamp</th>
73
+ <th>Message / Data</th>
74
+ </tr>
75
+ </thead>
76
+ <tbody>
77
+ {entries.map((entry, i) => (
78
+ <LogEntryRow key={i} entry={entry} />
79
+ ))}
80
+ </tbody>
81
+ </table>
82
+ )}
83
+
84
+ <div class="log-pagination">
85
+ <button
86
+ hx-get={`/api/settings/logs/entries?${baseQuery}&page=${page - 1}`}
87
+ hx-target="#log-entries"
88
+ hx-swap="outerHTML"
89
+ disabled={page <= 1}
90
+ >
91
+ Prev
92
+ </button>
93
+ <span>{page}</span>
94
+ <button
95
+ hx-get={`/api/settings/logs/entries?${baseQuery}&page=${page + 1}`}
96
+ hx-target="#log-entries"
97
+ hx-swap="outerHTML"
98
+ disabled={end >= total}
99
+ >
100
+ Next
101
+ </button>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ // Alias used by route for HTMX partial responses
108
+ export const LogEntriesFragment = LogEntryList;
@@ -0,0 +1,36 @@
1
+ interface LogFileListProps {
2
+ files: Array<{ name: string; size: number; modified: string }>;
3
+ selectedFile?: string;
4
+ }
5
+
6
+ function formatBytes(n: number): string {
7
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
8
+ return `${n} B`;
9
+ }
10
+
11
+ export function LogFileList({ files, selectedFile }: LogFileListProps) {
12
+ if (files.length === 0) {
13
+ return <p class="log-empty">No log files found in .planning/logs/</p>;
14
+ }
15
+
16
+ return (
17
+ <ul class="log-file-list">
18
+ {files.map((file) => {
19
+ const isSelected = file.name === selectedFile;
20
+ const date = new Date(file.modified).toLocaleDateString();
21
+ return (
22
+ <li key={file.name}>
23
+ <a
24
+ href={`/settings/logs?file=${encodeURIComponent(file.name)}`}
25
+ class={`log-file-item${isSelected ? ' log-file-item--active' : ''}`}
26
+ >
27
+ <span class="log-file-name">{file.name}</span>
28
+ <span class="log-file-size">{formatBytes(file.size)}</span>
29
+ <span class="log-file-date">{date}</span>
30
+ </a>
31
+ </li>
32
+ );
33
+ })}
34
+ </ul>
35
+ );
36
+ }
@@ -0,0 +1,129 @@
1
+ import { html } from 'hono/html';
2
+ import { LogFileList } from './LogFileList';
3
+ import { LogEntryList } from './LogEntryList';
4
+
5
+ interface LogViewerProps {
6
+ files: Array<{ name: string; size: number; modified: string }>;
7
+ selectedFile?: string;
8
+ entries?: object[];
9
+ total?: number;
10
+ page?: number;
11
+ pageSize?: number;
12
+ typeFilter?: string;
13
+ q?: string;
14
+ isLatest?: boolean;
15
+ }
16
+
17
+ export function LogViewer({
18
+ files,
19
+ selectedFile,
20
+ entries = [],
21
+ total = 0,
22
+ page = 1,
23
+ pageSize = 50,
24
+ typeFilter = '',
25
+ q = '',
26
+ isLatest = false,
27
+ }: LogViewerProps) {
28
+ return (
29
+ <div class="log-viewer-layout">
30
+ <aside class="log-file-sidebar">
31
+ <h2 class="config-section__title">Log Files</h2>
32
+ <LogFileList files={files} selectedFile={selectedFile} />
33
+ </aside>
34
+
35
+ <section class="log-main">
36
+ {!selectedFile ? (
37
+ <p class="log-prompt">Select a log file to view entries.</p>
38
+ ) : (
39
+ <>
40
+ <form
41
+ class="log-filters"
42
+ hx-get="/api/settings/logs/entries"
43
+ hx-target="#log-entries"
44
+ hx-swap="outerHTML"
45
+ hx-trigger="change, input delay:300ms from:[name=q]"
46
+ >
47
+ <input type="hidden" name="file" value={selectedFile} />
48
+ <input type="hidden" name="page" value="1" />
49
+
50
+ <select name="typeFilter">
51
+ <option value="" selected={typeFilter === ''}>All types</option>
52
+ <option value="hook" selected={typeFilter === 'hook'}>hook</option>
53
+ <option value="task" selected={typeFilter === 'task'}>task</option>
54
+ <option value="agent" selected={typeFilter === 'agent'}>agent</option>
55
+ <option value="error" selected={typeFilter === 'error'}>error</option>
56
+ <option value="info" selected={typeFilter === 'info'}>info</option>
57
+ <option value="warn" selected={typeFilter === 'warn'}>warn</option>
58
+ <option value="debug" selected={typeFilter === 'debug'}>debug</option>
59
+ </select>
60
+
61
+ <input
62
+ type="text"
63
+ name="q"
64
+ placeholder="Search entries..."
65
+ value={q || ''}
66
+ />
67
+ </form>
68
+
69
+ <div id="log-entries-container">
70
+ <LogEntryList
71
+ entries={entries}
72
+ total={total}
73
+ page={page}
74
+ pageSize={pageSize}
75
+ file={selectedFile}
76
+ typeFilter={typeFilter}
77
+ q={q}
78
+ />
79
+ </div>
80
+
81
+ {isLatest && (
82
+ <>
83
+ <div
84
+ class="log-tail-indicator"
85
+ x-data={`logTail({ file: '${selectedFile}' })`}
86
+ x-init="start()"
87
+ >
88
+ <span class="tail-dot" x-bind:class="{ 'tail-dot--active': connected }"></span>
89
+ <span x-text="connected ? 'Live' : 'Connecting...'"></span>
90
+ <span class="tail-count" x-text="newCount + ' new entries'"></span>
91
+ </div>
92
+ <div id="log-tail-entries" class="log-tail-entries"></div>
93
+ </>
94
+ )}
95
+ </>
96
+ )}
97
+ </section>
98
+
99
+ {html`<script>
100
+ function logTail({ file }) {
101
+ return {
102
+ connected: false,
103
+ newCount: 0,
104
+ es: null,
105
+ start() {
106
+ this.es = new EventSource('/api/settings/logs/tail?file=' + encodeURIComponent(file));
107
+ this.es.addEventListener('log-entry', (e) => {
108
+ this.newCount++;
109
+ const container = document.getElementById('log-tail-entries');
110
+ if (!container) return;
111
+ const row = document.createElement('div');
112
+ row.className = 'log-tail-row';
113
+ try {
114
+ const entry = JSON.parse(e.data);
115
+ row.textContent = JSON.stringify(entry);
116
+ } catch { row.textContent = e.data; }
117
+ container.prepend(row);
118
+ });
119
+ this.es.onopen = () => { this.connected = true; };
120
+ this.es.onerror = () => { this.connected = false; };
121
+ window.addEventListener('beforeunload', () => this.stop());
122
+ },
123
+ stop() { if (this.es) this.es.close(); }
124
+ };
125
+ }
126
+ </script>`}
127
+ </div>
128
+ );
129
+ }