@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.
- package/CHANGELOG.md +28 -0
- package/dashboard/public/css/settings.css +314 -0
- package/dashboard/public/css/timeline.css +240 -0
- package/dashboard/src/components/Layout.tsx +4 -0
- package/dashboard/src/components/settings/ConfigEditor.tsx +399 -0
- package/dashboard/src/components/settings/LogEntryList.tsx +108 -0
- package/dashboard/src/components/settings/LogFileList.tsx +36 -0
- package/dashboard/src/components/settings/LogViewer.tsx +129 -0
- package/dashboard/src/components/settings/SettingsPage.tsx +44 -0
- package/dashboard/src/components/timeline/AnalyticsPanel.tsx +99 -0
- package/dashboard/src/components/timeline/DependencyGraph.tsx +23 -0
- package/dashboard/src/components/timeline/TimelinePage.tsx +124 -0
- package/dashboard/src/index.tsx +4 -0
- package/dashboard/src/routes/settings.routes.tsx +231 -0
- package/dashboard/src/routes/timeline.routes.tsx +50 -0
- package/dashboard/src/services/analytics.service.d.ts +24 -0
- package/dashboard/src/services/config.service.d.ts +9 -0
- package/dashboard/src/services/local-llm-metrics.service.d.ts +26 -0
- package/dashboard/src/services/log.service.d.ts +13 -0
- package/dashboard/src/services/timeline.service.d.ts +20 -0
- package/dashboard/src/services/timeline.service.js +174 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/scripts/config-schema.json +12 -0
- package/plugins/pbr/scripts/context-budget-check.js +4 -1
- package/plugins/pbr/scripts/enforce-pbr-workflow.js +218 -0
- package/plugins/pbr/scripts/pre-bash-dispatch.js +7 -0
- package/plugins/pbr/scripts/pre-write-dispatch.js +30 -18
- package/plugins/pbr/scripts/progress-tracker.js +1 -1
- 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
|
+
}
|