opencode-global-habit-profile 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +87 -2
- package/dist/assets/index-CMgH-UX9.js +21 -0
- package/dist/assets/index-CX37tAhW.css +1 -0
- package/dist/index.html +4 -0
- package/docs/mcp-setup.md +32 -0
- package/docs/metrics.md +200 -0
- package/index.js +501 -0
- package/package.json +51 -4
- package/src/App.vue +753 -0
- package/src/mcp.js +39 -0
- package/src/metrics.js +614 -0
- package/src/plugin-stats.js +215 -0
- package/src/store.js +296 -0
- package/src/ui-model.js +184 -0
- package/src/ui-navigation.js +13 -0
- package/src/ui-server.js +300 -0
- package/src/ui.css +221 -0
- package/src/ui.js +5 -0
package/src/App.vue
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { checkpointTitle, dailyTrend, groupCheckpoints, groupTodos, metricsRange, projectDirectoryLabel, reportGroups, sortMetricRows, tokenBars, trendBars } from './ui-model.js'
|
|
4
|
+
import { viewFromHash, watchHashView } from './ui-navigation.js'
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
setup() {
|
|
8
|
+
const data = ref(null)
|
|
9
|
+
const error = ref('')
|
|
10
|
+
const language = ref('es')
|
|
11
|
+
const theme = ref('dark')
|
|
12
|
+
const activeView = ref(viewFromHash(window.location.hash))
|
|
13
|
+
const busy = ref(false)
|
|
14
|
+
const includeArchived = ref(false)
|
|
15
|
+
const metrics = ref(null)
|
|
16
|
+
const metricsError = ref('')
|
|
17
|
+
const metricsLoading = ref(false)
|
|
18
|
+
const rangeDays = ref(30)
|
|
19
|
+
const metricProject = ref('')
|
|
20
|
+
const metricAgent = ref('')
|
|
21
|
+
const metricProvider = ref('')
|
|
22
|
+
const metricModel = ref('')
|
|
23
|
+
const metricOptions = ref({ projects: [], agents: [], providers: [], models: [] })
|
|
24
|
+
const token = document.querySelector('meta[name="habit-token"]')?.content
|
|
25
|
+
const t = (es, en) => language.value === 'es' ? es : en
|
|
26
|
+
const numberFormat = computed(() => new Intl.NumberFormat(language.value, { maximumFractionDigits: 0 }))
|
|
27
|
+
const decimalFormat = computed(() => new Intl.NumberFormat(language.value, { maximumFractionDigits: 1 }))
|
|
28
|
+
const compactFormat = computed(() => new Intl.NumberFormat(language.value, { notation: 'compact', maximumFractionDigits: 1 }))
|
|
29
|
+
const percentFormat = computed(() => new Intl.NumberFormat(language.value, { style: 'percent', maximumFractionDigits: 1 }))
|
|
30
|
+
const formatNumber = (value) => numberFormat.value.format(Number(value) || 0)
|
|
31
|
+
const formatCompact = (value) => compactFormat.value.format(Number(value) || 0)
|
|
32
|
+
const formatPercent = (value) => percentFormat.value.format(Number(value) || 0)
|
|
33
|
+
const tokenBreakdown = (row) => {
|
|
34
|
+
const tokens = row?.tokens ?? {}
|
|
35
|
+
return [
|
|
36
|
+
`${t('entrada', 'input')} ${formatCompact(tokens.input)}`,
|
|
37
|
+
`${t('caché', 'cache')} ${formatCompact(tokens.cacheRead)}`,
|
|
38
|
+
`${t('salida', 'output')} ${formatCompact(tokens.output)}`,
|
|
39
|
+
`${t('raz.', 'reason.')} ${formatCompact(tokens.reasoning)}`,
|
|
40
|
+
].join(' · ')
|
|
41
|
+
}
|
|
42
|
+
const seriesLabel = (key) => ({
|
|
43
|
+
input: t('Entrada sin caché', 'Uncached input'),
|
|
44
|
+
cache: t('Caché leída', 'Cache read'),
|
|
45
|
+
output: t('Salida', 'Output'),
|
|
46
|
+
}[key] ?? key)
|
|
47
|
+
const metricKindLabel = (kind) => ({
|
|
48
|
+
primary: t('Principal', 'Primary'),
|
|
49
|
+
subagent: t('Subagente', 'Subagent'),
|
|
50
|
+
title: t('Título', 'Title'),
|
|
51
|
+
compaction: t('Compactación', 'Compaction'),
|
|
52
|
+
generate: t('Generación', 'Generate'),
|
|
53
|
+
other: t('Otro', 'Other'),
|
|
54
|
+
}[kind] ?? kind)
|
|
55
|
+
const projectOptionLabel = (project) => project.directory || project.key
|
|
56
|
+
const metricsEmpty = computed(() => Boolean(metrics.value) && metrics.value.totals?.steps === 0)
|
|
57
|
+
const hasMetricFilters = computed(() => Boolean(metricProject.value || metricAgent.value || metricProvider.value || metricModel.value))
|
|
58
|
+
const tokenChart = computed(() => tokenBars(metrics.value?.series ?? []))
|
|
59
|
+
const firstMetricDay = computed(() => metrics.value?.series?.[0]?.day ?? '')
|
|
60
|
+
const lastMetricDay = computed(() => metrics.value?.series?.at(-1)?.day ?? '')
|
|
61
|
+
const cacheDenominator = computed(() => {
|
|
62
|
+
const tokens = metrics.value?.totals?.tokens
|
|
63
|
+
return tokens ? tokens.input + tokens.cacheRead + tokens.cacheWrite : 0
|
|
64
|
+
})
|
|
65
|
+
const sortTables = ['agent', 'model', 'project', 'kind']
|
|
66
|
+
const sortState = ref(Object.fromEntries(sortTables.map((table) => [table, { key: 'steps', direction: 'desc' }])))
|
|
67
|
+
const sortRows = (table, rows, nameOf = (row) => row?.key ?? '') => sortMetricRows(
|
|
68
|
+
rows.map((row) => ({ ...row, name: nameOf(row) })),
|
|
69
|
+
sortState.value[table].key,
|
|
70
|
+
sortState.value[table].direction,
|
|
71
|
+
)
|
|
72
|
+
const agentRows = computed(() => sortRows('agent', metrics.value?.byAgent ?? []))
|
|
73
|
+
const modelRows = computed(() => sortRows('model', metrics.value?.byModel ?? [], (row) => `${row.key ?? ''} ${row.provider ?? ''}`))
|
|
74
|
+
const projectRows = computed(() => sortRows('project', metrics.value?.byProject ?? [], (row) => row.directory || row.key || ''))
|
|
75
|
+
const kindRows = computed(() => sortRows('kind', metrics.value?.byKind ?? [], (row) => metricKindLabel(row.key)))
|
|
76
|
+
function toggleSort(table, key) {
|
|
77
|
+
const state = sortState.value[table]
|
|
78
|
+
state.direction = state.key === key ? (state.direction === 'asc' ? 'desc' : 'asc') : (key === 'name' ? 'asc' : 'desc')
|
|
79
|
+
state.key = key
|
|
80
|
+
}
|
|
81
|
+
const ariaSort = (table, key) => sortState.value[table].key !== key
|
|
82
|
+
? 'none'
|
|
83
|
+
: sortState.value[table].direction === 'asc' ? 'ascending' : 'descending'
|
|
84
|
+
const sortIndicator = (table, key) => sortState.value[table].key !== key
|
|
85
|
+
? ''
|
|
86
|
+
: sortState.value[table].direction === 'asc' ? '↑' : '↓'
|
|
87
|
+
const plugin = computed(() => metrics.value?.plugin ?? null)
|
|
88
|
+
const pluginHooks = computed(() => Object.entries(plugin.value?.hooks ?? {}).map(([name, hook]) => ({
|
|
89
|
+
name,
|
|
90
|
+
invocations: Number(hook?.invocations) || 0,
|
|
91
|
+
errors: Number(hook?.errors) || 0,
|
|
92
|
+
p50: hook?.latency?.p50 ?? null,
|
|
93
|
+
p95: hook?.latency?.p95 ?? null,
|
|
94
|
+
})))
|
|
95
|
+
const pluginErrors = computed(() => ["hooks", "store", "server", "collector"].map((key) => ({ key, count: Number(plugin.value?.errors?.[key]) || 0 })))
|
|
96
|
+
const pluginHookCalls = computed(() => pluginHooks.value.reduce((sum, hook) => sum + hook.invocations, 0))
|
|
97
|
+
const pluginHookErrors = computed(() => pluginHooks.value.reduce((sum, hook) => sum + hook.errors, 0))
|
|
98
|
+
const pluginErrorTotal = computed(() => pluginErrors.value.reduce((sum, error) => sum + error.count, 0))
|
|
99
|
+
const pluginCurrent = computed(() => plugin.value?.current ?? null)
|
|
100
|
+
const pluginEmpty = computed(() => {
|
|
101
|
+
const value = plugin.value
|
|
102
|
+
if (!value) return true
|
|
103
|
+
const hooks = Object.values(value.hooks ?? {}).some((hook) => (Number(hook?.invocations) || 0) > 0 || (Number(hook?.errors) || 0) > 0)
|
|
104
|
+
const storage = (Number(value.store?.reads) || 0) + (Number(value.store?.writes) || 0) + (Number(value.store?.errors) || 0)
|
|
105
|
+
const sse = (Number(value.sse?.clients) || 0) + (Number(value.sse?.peakClients) || 0) + (Number(value.sse?.refreshes) || 0)
|
|
106
|
+
const requests = ["2xx", "4xx", "5xx"].some((key) => (Number(value.requests?.[key]) || 0) > 0)
|
|
107
|
+
const context = (Number(value.context?.injections) || 0) + (Number(value.context?.injectedChars) || 0) + (Number(value.context?.notices?.checkpoint) || 0) + (Number(value.context?.notices?.todo) || 0)
|
|
108
|
+
const current = value.current && Object.values(value.current).some((count) => (Number(count) || 0) > 0)
|
|
109
|
+
const errors = (Number(value.errors?.hooks) || 0) + (Number(value.errors?.store) || 0) + (Number(value.errors?.server) || 0) + (Number(value.errors?.collector) || 0)
|
|
110
|
+
const flush = (Number(value.flush?.failures) || 0) + (Number(value.flush?.pending) || 0)
|
|
111
|
+
return !hooks && !storage && !sse && !requests && !context && !current && !errors && !flush
|
|
112
|
+
})
|
|
113
|
+
const formatMillis = (value) => value === null || value === undefined ? '—' : `${formatNumber(value)} ms`
|
|
114
|
+
const formatBytes = (value) => {
|
|
115
|
+
if (value === null || value === undefined) return '—'
|
|
116
|
+
const bytes = Number(value)
|
|
117
|
+
if (!Number.isFinite(bytes) || bytes < 0) return '—'
|
|
118
|
+
if (bytes < 1024) return `${formatNumber(bytes)} B`
|
|
119
|
+
if (bytes < 1024 * 1024) return `${decimalFormat.value.format(bytes / 1024)} KB`
|
|
120
|
+
return `${decimalFormat.value.format(bytes / (1024 * 1024))} MB`
|
|
121
|
+
}
|
|
122
|
+
const formatTime = (value) => {
|
|
123
|
+
if (!value) return '—'
|
|
124
|
+
const date = new Date(value)
|
|
125
|
+
return Number.isFinite(date.getTime()) ? date.toLocaleTimeString(language.value) : '—'
|
|
126
|
+
}
|
|
127
|
+
const pluginHookLabel = (name) => ({
|
|
128
|
+
"session.prompt": t('Prompt de sesión', 'Session prompt'),
|
|
129
|
+
"session.context": t('Contexto de sesión', 'Session context'),
|
|
130
|
+
"tool.execute.after": t('Después de herramienta', 'After tool'),
|
|
131
|
+
"shell.create.before": t('Antes de shell', 'Before shell'),
|
|
132
|
+
}[name] ?? name)
|
|
133
|
+
const pluginErrorLabel = (key) => ({
|
|
134
|
+
hooks: t('Hooks', 'Hooks'),
|
|
135
|
+
store: t('Almacenamiento', 'Storage'),
|
|
136
|
+
server: t('Servidor', 'Server'),
|
|
137
|
+
collector: t('Colector', 'Collector'),
|
|
138
|
+
}[key] ?? key)
|
|
139
|
+
const scopes = computed(() => groupCheckpoints(data.value?.checkpoints ?? []))
|
|
140
|
+
const projectDirectories = computed(() => new Map(scopes.value.map(({ project, directoryName }) => [project, directoryName])))
|
|
141
|
+
const todoGroups = computed(() => groupTodos(data.value?.todos ?? [], includeArchived.value, projectDirectories.value))
|
|
142
|
+
const todoStatuses = ['pending', 'completed', 'archived']
|
|
143
|
+
const reports = computed(() => reportGroups(data.value?.habits.counts ?? {}))
|
|
144
|
+
const trend = computed(() => dailyTrend(data.value?.habits.daily ?? {}))
|
|
145
|
+
const bars = computed(() => trendBars(trend.value))
|
|
146
|
+
const hasHabitData = computed(() => reports.value.some((group) => group.total > 0) || trend.value.length > 0)
|
|
147
|
+
const kindLabel = (kind) => ({
|
|
148
|
+
tool: t('Herramienta', 'Tool'),
|
|
149
|
+
cli: t('Comando', 'Command'),
|
|
150
|
+
}[kind] ?? '')
|
|
151
|
+
const reportTitle = (key) => ({
|
|
152
|
+
tools: t('Herramientas', 'Tools'),
|
|
153
|
+
commands: t('Comandos', 'Commands'),
|
|
154
|
+
}[key] ?? key)
|
|
155
|
+
const signalLabel = (signal) => signal === 'request'
|
|
156
|
+
? t('solicitud explícita', 'explicit request')
|
|
157
|
+
: t('uso observado', 'observed use')
|
|
158
|
+
const todoStatusLabel = (status) => ({
|
|
159
|
+
pending: t('Pendientes', 'Pending'),
|
|
160
|
+
completed: t('Completados', 'Completed'),
|
|
161
|
+
archived: t('Archivados', 'Archived'),
|
|
162
|
+
}[status] ?? status)
|
|
163
|
+
|
|
164
|
+
watch(language, (value) => { document.documentElement.lang = value })
|
|
165
|
+
watch(theme, (value) => { document.documentElement.dataset.theme = value }, { immediate: true })
|
|
166
|
+
watch([rangeDays, metricProject, metricAgent, metricProvider, metricModel], loadMetrics)
|
|
167
|
+
|
|
168
|
+
async function request(path, body) {
|
|
169
|
+
const response = await fetch(path, {
|
|
170
|
+
method: body ? 'POST' : 'GET',
|
|
171
|
+
cache: 'no-store',
|
|
172
|
+
headers: body ? { 'content-type': 'application/json', origin: location.origin, 'x-habit-token': token } : {},
|
|
173
|
+
body: body && JSON.stringify(body),
|
|
174
|
+
})
|
|
175
|
+
if (!response.ok) throw new Error(`${t('El servidor local rechazó la solicitud', 'The local server rejected the request')} (${response.status}).`)
|
|
176
|
+
return body ? null : response.json()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function load() {
|
|
180
|
+
try { data.value = await request('/api/data'); error.value = '' } catch (e) { error.value = e.message }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function mergeMetricOptions(result) {
|
|
184
|
+
const current = metricOptions.value
|
|
185
|
+
const projects = new Map(current.projects.map((project) => [project.key, project]))
|
|
186
|
+
for (const row of result?.byProject ?? []) if (row?.key) projects.set(row.key, { key: row.key, directory: row.directory || '' })
|
|
187
|
+
const agents = new Set(current.agents)
|
|
188
|
+
for (const row of result?.byAgent ?? []) if (row?.key) agents.add(row.key)
|
|
189
|
+
const providers = new Set(current.providers)
|
|
190
|
+
const models = new Set(current.models)
|
|
191
|
+
for (const row of result?.byModel ?? []) {
|
|
192
|
+
if (row?.key) models.add(row.key)
|
|
193
|
+
if (row?.provider) providers.add(row.provider)
|
|
194
|
+
}
|
|
195
|
+
metricOptions.value = {
|
|
196
|
+
projects: [...projects.values()].sort((a, b) => (a.directory || a.key).localeCompare(b.directory || b.key)),
|
|
197
|
+
agents: [...agents].sort((a, b) => a.localeCompare(b)),
|
|
198
|
+
providers: [...providers].sort((a, b) => a.localeCompare(b)),
|
|
199
|
+
models: [...models].sort((a, b) => a.localeCompare(b)),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let metricsRequest = 0
|
|
204
|
+
async function loadMetrics() {
|
|
205
|
+
const requestId = ++metricsRequest
|
|
206
|
+
metricsLoading.value = true
|
|
207
|
+
metricsError.value = ''
|
|
208
|
+
try {
|
|
209
|
+
const { from, to } = metricsRange(rangeDays.value)
|
|
210
|
+
const params = new URLSearchParams({ from, to })
|
|
211
|
+
if (metricProject.value) params.set('project', metricProject.value)
|
|
212
|
+
if (metricAgent.value) params.set('agent', metricAgent.value)
|
|
213
|
+
if (metricProvider.value) params.set('provider', metricProvider.value)
|
|
214
|
+
if (metricModel.value) params.set('model', metricModel.value)
|
|
215
|
+
const result = await request(`/api/metrics?${params}`)
|
|
216
|
+
if (requestId !== metricsRequest) return
|
|
217
|
+
metrics.value = result
|
|
218
|
+
mergeMetricOptions(result)
|
|
219
|
+
} catch (e) {
|
|
220
|
+
if (requestId === metricsRequest) metricsError.value = e.message
|
|
221
|
+
} finally {
|
|
222
|
+
if (requestId === metricsRequest) metricsLoading.value = false
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function clearMetricFilters() {
|
|
227
|
+
metricProject.value = ''
|
|
228
|
+
metricAgent.value = ''
|
|
229
|
+
metricProvider.value = ''
|
|
230
|
+
metricModel.value = ''
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function mutate(path, body, confirmText) {
|
|
234
|
+
if (busy.value || (confirmText && !confirm(confirmText))) return
|
|
235
|
+
busy.value = true
|
|
236
|
+
error.value = ''
|
|
237
|
+
try { await request(path, body); await load() } catch (e) { error.value = e.message } finally { busy.value = false }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function edit(item) {
|
|
241
|
+
if (busy.value) return
|
|
242
|
+
const summary = prompt(t('Resumen del punto guardado', 'Checkpoint summary'), item.summary)
|
|
243
|
+
if (summary === null) return
|
|
244
|
+
if (!summary.trim() || summary.length > 240) { error.value = t('Escribe entre 1 y 240 caracteres.', 'Enter 1–240 characters.'); return }
|
|
245
|
+
await mutate('/api/checkpoint/edit', { project: item.project, context: item.context, id: item.id, summary })
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function removeCheckpoint(item) {
|
|
249
|
+
return mutate('/api/checkpoint/delete', { project: item.project, context: item.context, id: item.id },
|
|
250
|
+
t('¿Eliminar este punto guardado?', 'Delete this checkpoint?'))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function removeScope(scope) {
|
|
254
|
+
const message = t(
|
|
255
|
+
`¿Eliminar todos los puntos de este contexto (${scope.items.length})?\nProyecto: ${JSON.stringify(scope.project)}\nContexto: ${JSON.stringify(scope.context)}`,
|
|
256
|
+
`Delete all checkpoints in this context (${scope.items.length})?\nProject: ${JSON.stringify(scope.project)}\nContext: ${JSON.stringify(scope.context)}`,
|
|
257
|
+
)
|
|
258
|
+
return mutate('/api/scope/delete', { project: scope.project, context: scope.context }, message)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function clearHabits() {
|
|
262
|
+
return mutate('/api/habits/clear', { confirm: true }, t(
|
|
263
|
+
'¿Borrar los conteos acumulados y tendencias de hábitos? Esta acción no se puede deshacer. Los checkpoints no se borrarán.',
|
|
264
|
+
'Clear all accumulated habit counts and trends? This cannot be undone. Checkpoints will not be deleted.',
|
|
265
|
+
))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let stopWatchingHash = () => {}
|
|
269
|
+
let eventSource, refreshTimer
|
|
270
|
+
const refreshOnVisible = () => { if (!document.hidden) { load(); loadMetrics() } }
|
|
271
|
+
onMounted(() => {
|
|
272
|
+
stopWatchingHash = watchHashView(window, (view) => {
|
|
273
|
+
activeView.value = view
|
|
274
|
+
window.scrollTo(0, 0)
|
|
275
|
+
})
|
|
276
|
+
load()
|
|
277
|
+
loadMetrics()
|
|
278
|
+
if (typeof EventSource === 'function') {
|
|
279
|
+
eventSource = new EventSource('/api/events')
|
|
280
|
+
eventSource.onmessage = () => {
|
|
281
|
+
clearTimeout(refreshTimer)
|
|
282
|
+
refreshTimer = setTimeout(() => { load(); loadMetrics() }, 150)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
document.addEventListener('visibilitychange', refreshOnVisible)
|
|
286
|
+
})
|
|
287
|
+
onBeforeUnmount(() => {
|
|
288
|
+
stopWatchingHash()
|
|
289
|
+
eventSource?.close()
|
|
290
|
+
clearTimeout(refreshTimer)
|
|
291
|
+
metricsRequest++
|
|
292
|
+
document.removeEventListener('visibilitychange', refreshOnVisible)
|
|
293
|
+
})
|
|
294
|
+
return { data, error, language, theme, activeView, busy, includeArchived, t, scopes, todoGroups, todoStatuses, todoStatusLabel, reports, trend, bars, hasHabitData, reportTitle, signalLabel, kindLabel, checkpointTitle, projectDirectoryLabel, load, edit, removeCheckpoint, removeScope, clearHabits, metrics, metricsError, metricsLoading, rangeDays, metricProject, metricAgent, metricProvider, metricModel, metricOptions, metricsEmpty, hasMetricFilters, tokenChart, firstMetricDay, lastMetricDay, cacheDenominator, formatNumber, formatCompact, formatPercent, tokenBreakdown, agentRows, modelRows, projectRows, kindRows, toggleSort, ariaSort, sortIndicator, seriesLabel, metricKindLabel, projectOptionLabel, clearMetricFilters, loadMetrics, plugin, pluginHooks, pluginErrors, pluginHookCalls, pluginHookErrors, pluginErrorTotal, pluginCurrent, pluginEmpty, formatMillis, formatBytes, formatTime, pluginHookLabel, pluginErrorLabel }
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
</script>
|
|
298
|
+
|
|
299
|
+
<template>
|
|
300
|
+
<div class="app-shell" id="inicio">
|
|
301
|
+
<aside class="sidebar">
|
|
302
|
+
<a class="brand" href="#reports">Habit Memory</a>
|
|
303
|
+
<nav class="sidebar-nav" :aria-label="t('Navegación principal', 'Main navigation')">
|
|
304
|
+
<a href="#reports" :aria-current="activeView === 'reports' ? 'page' : undefined">{{ t('Informes', 'Reports') }}</a>
|
|
305
|
+
<a href="#checkpoints" :aria-current="activeView === 'checkpoints' ? 'page' : undefined">{{ t('Puntos guardados', 'Saved checkpoints') }}</a>
|
|
306
|
+
<a href="#todos" :aria-current="activeView === 'todos' ? 'page' : undefined">{{ t('TODOs', 'TODOs') }}</a>
|
|
307
|
+
<a href="#metrics" :aria-current="activeView === 'metrics' ? 'page' : undefined">{{ t('Métricas', 'Metrics') }}</a>
|
|
308
|
+
</nav>
|
|
309
|
+
<p class="local-note">{{ t('Tus datos permanecen en este equipo.', 'Your data stays on this machine.') }}</p>
|
|
310
|
+
</aside>
|
|
311
|
+
|
|
312
|
+
<div class="workspace">
|
|
313
|
+
<header class="masthead">
|
|
314
|
+
<div>
|
|
315
|
+
<p class="eyebrow">{{ t('MEMORIA DE TRABAJO', 'WORKING MEMORY') }}</p>
|
|
316
|
+
<h1>{{ t('Tu memoria de trabajo.', 'Your working memory.') }}</h1>
|
|
317
|
+
<p class="intro">{{ t('Hábitos, puntos guardados, TODOs y métricas de uso de este equipo.', 'Stored habits, checkpoints, TODOs, and usage metrics on this machine.') }}</p>
|
|
318
|
+
</div>
|
|
319
|
+
<div class="masthead-controls">
|
|
320
|
+
<label class="language"><span>{{ t('Idioma', 'Language') }}</span>
|
|
321
|
+
<select v-model="language">
|
|
322
|
+
<option value="es">ES</option>
|
|
323
|
+
<option value="en">EN</option>
|
|
324
|
+
</select>
|
|
325
|
+
</label>
|
|
326
|
+
<button class="theme-toggle" type="button" :aria-pressed="theme === 'light'"
|
|
327
|
+
:aria-label="theme === 'dark' ? t('Cambiar a tema claro', 'Switch to light theme') : t('Cambiar a tema oscuro', 'Switch to dark theme')"
|
|
328
|
+
@click="theme = theme === 'dark' ? 'light' : 'dark'">
|
|
329
|
+
{{ theme === 'dark' ? t('Claro', 'Light') : t('Oscuro', 'Dark') }}
|
|
330
|
+
</button>
|
|
331
|
+
</div>
|
|
332
|
+
</header>
|
|
333
|
+
|
|
334
|
+
<main>
|
|
335
|
+
<p class="page-note">{{ t('Este índice contiene resúmenes, hábitos agregados, TODOs persistentes y métricas locales del complemento.', 'This index contains summaries, aggregate habits, persistent TODOs, and local metrics saved by the plugin.') }}</p>
|
|
336
|
+
<p v-if="error" class="notice" role="alert">{{ error }} <button type="button" :disabled="busy" @click="load">{{ t('Reintentar', 'Retry') }}</button></p>
|
|
337
|
+
<p v-if="busy" role="status">{{ t('Guardando cambios…', 'Saving changes…') }}</p>
|
|
338
|
+
<p v-if="!data && !error" role="status">{{ t('Leyendo la memoria local…', 'Reading local memory…') }}</p>
|
|
339
|
+
<template v-if="data">
|
|
340
|
+
<section id="reports" v-show="activeView === 'reports'" aria-labelledby="reports-heading">
|
|
341
|
+
<header class="report-heading">
|
|
342
|
+
<div>
|
|
343
|
+
<h2 id="reports-heading">{{ t('Informes de actividad', 'Activity reports') }}</h2>
|
|
344
|
+
<p class="section-note">{{ t('Conteos acumulados heredados incluidos; las tendencias empiezan con la instrumentación nueva y no reconstruyen el pasado.', 'Includes legacy accumulated counts; trends start with the new instrumentation and do not reconstruct the past.') }}</p>
|
|
345
|
+
</div>
|
|
346
|
+
<button type="button" class="action-delete" :disabled="busy || !hasHabitData" @click="clearHabits">{{ t('Borrar hábitos', 'Clear habits') }}</button>
|
|
347
|
+
</header>
|
|
348
|
+
<div class="report-grid">
|
|
349
|
+
<article v-for="group in reports" :key="group.key" class="report-card">
|
|
350
|
+
<header class="report-card-head">
|
|
351
|
+
<h3>{{ reportTitle(group.key) }}</h3>
|
|
352
|
+
<strong class="report-total">{{ group.total }}</strong>
|
|
353
|
+
</header>
|
|
354
|
+
<ol v-if="group.items.length" class="report-ranking">
|
|
355
|
+
<li v-for="item in group.items" :key="item.key">
|
|
356
|
+
<div class="report-label">
|
|
357
|
+
<span>{{ signalLabel(item.signal) }} · {{ kindLabel(item.kind) }} · {{ item.name }}</span>
|
|
358
|
+
<code>{{ item.key }}</code>
|
|
359
|
+
</div>
|
|
360
|
+
<strong>{{ item.count }}</strong>
|
|
361
|
+
</li>
|
|
362
|
+
</ol>
|
|
363
|
+
<p v-else class="empty-report">{{ t('Sin conteos.', 'No counts yet.') }}</p>
|
|
364
|
+
</article>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
<div class="trend-panel">
|
|
368
|
+
<h3>{{ t('Tendencia diaria · UTC', 'Daily trend · UTC') }}</h3>
|
|
369
|
+
<p v-if="!trend.length" class="empty-report">{{ t('Aún no hay actividad diaria. La serie comienza ahora; los conteos anteriores no tienen fecha asignada.', 'No daily activity yet. The series starts now; earlier counts have no assigned date.') }}</p>
|
|
370
|
+
<template v-else>
|
|
371
|
+
<p class="section-note">{{ t(`Días registrados: ${trend[0].date} – ${trend.at(-1).date}.`, `Recorded days: ${trend[0].date} – ${trend.at(-1).date}.`) }}</p>
|
|
372
|
+
<ul class="trend-legend" :aria-label="t('Leyenda del gráfico', 'Chart legend')">
|
|
373
|
+
<li class="tools-series">{{ t('Herramientas', 'Tools') }}</li>
|
|
374
|
+
<li class="commands-series">{{ t('Comandos', 'Commands') }}</li>
|
|
375
|
+
</ul>
|
|
376
|
+
<svg class="trend-chart" viewBox="0 0 720 180" role="img" aria-labelledby="trend-title trend-desc">
|
|
377
|
+
<title id="trend-title">{{ t('Conteos diarios por categoría', 'Daily counts by category') }}</title>
|
|
378
|
+
<desc id="trend-desc">{{ t(`Gráfico de barras con ${trend.length} días registrados en UTC; no hay historial anterior a la instrumentación.`, `Bar chart with ${trend.length} recorded days in UTC; there is no history before instrumentation.`) }}</desc>
|
|
379
|
+
<line x1="28" y1="152" x2="704" y2="152" class="chart-axis" />
|
|
380
|
+
<rect v-for="bar in bars" :key="`${bar.date}-${bar.key}`" :class="`${bar.key}-series`"
|
|
381
|
+
:x="bar.x" :y="bar.y" :width="bar.width" :height="bar.height">
|
|
382
|
+
<title>{{ `${bar.date} · ${reportTitle(bar.key)}: ${bar.count}` }}</title>
|
|
383
|
+
</rect>
|
|
384
|
+
<text :x="trend.length === 1 ? 360 : 28" y="173" :text-anchor="trend.length === 1 ? 'middle' : 'start'">{{ trend[0].date }}</text>
|
|
385
|
+
<text v-if="trend.length > 1" x="704" y="173" text-anchor="end">{{ trend.at(-1).date }}</text>
|
|
386
|
+
</svg>
|
|
387
|
+
</template>
|
|
388
|
+
</div>
|
|
389
|
+
</section>
|
|
390
|
+
|
|
391
|
+
<section id="checkpoints" v-show="activeView === 'checkpoints'" aria-labelledby="checkpoints-heading">
|
|
392
|
+
<h2 id="checkpoints-heading">{{ t('Puntos guardados', 'Saved checkpoints') }}</h2>
|
|
393
|
+
<section class="stat-strip checkpoint-summary" :aria-label="t('Resumen de la memoria', 'Memory overview')">
|
|
394
|
+
<article class="stat-card">
|
|
395
|
+
<span>{{ t('Puntos guardados', 'Saved checkpoints') }}</span>
|
|
396
|
+
<strong>{{ data.checkpoints.length }}</strong>
|
|
397
|
+
</article>
|
|
398
|
+
</section>
|
|
399
|
+
<p v-if="!scopes.length">{{ t('No hay puntos guardados.', 'No checkpoints saved.') }}</p>
|
|
400
|
+
<div v-else class="scope-list">
|
|
401
|
+
<details v-for="scope in scopes" :key="scope.key" class="scope">
|
|
402
|
+
<summary class="project-summary">
|
|
403
|
+
<div class="scope-provenance">
|
|
404
|
+
<span class="scope-caption">{{ t('Directorio', 'Directory') }} <strong class="scope-directory">{{ projectDirectoryLabel(scope, language) }}</strong></span>
|
|
405
|
+
<span class="scope-caption">{{ t('Proyecto', 'Project') }} · {{ scope.items.length }} {{ scope.items.length === 1 ? t('punto', 'checkpoint') : t('puntos', 'checkpoints') }}</span>
|
|
406
|
+
<code>{{ scope.project }}</code>
|
|
407
|
+
</div>
|
|
408
|
+
</summary>
|
|
409
|
+
<div class="project-checkpoints">
|
|
410
|
+
<div v-for="item in scope.items" :key="item.id" class="entry">
|
|
411
|
+
<div class="entry-content">
|
|
412
|
+
<h3 class="entry-title">{{ checkpointTitle(item, language) }}</h3>
|
|
413
|
+
<p class="entry-summary">{{ item.summary }}</p>
|
|
414
|
+
<p class="entry-context"><span>{{ t('Contexto', 'Context') }}</span> <code>{{ item.context }}</code></p>
|
|
415
|
+
<time :datetime="item.createdAt">{{ new Date(item.createdAt).toLocaleString(language) }}</time>
|
|
416
|
+
</div>
|
|
417
|
+
<div class="entry-actions">
|
|
418
|
+
<button type="button" class="action-edit" :disabled="busy"
|
|
419
|
+
:aria-label="`${t('Editar punto guardado', 'Edit checkpoint')}: ${item.summary}`"
|
|
420
|
+
@click="edit(item)">{{ t('Editar', 'Edit') }}</button>
|
|
421
|
+
<button type="button" class="action-delete" :disabled="busy"
|
|
422
|
+
:aria-label="`${t('Eliminar punto guardado', 'Delete checkpoint')}: ${item.summary}`"
|
|
423
|
+
@click="removeCheckpoint(item)">{{ t('Eliminar', 'Delete') }}</button>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
</details>
|
|
428
|
+
</div>
|
|
429
|
+
</section>
|
|
430
|
+
|
|
431
|
+
<section id="todos" v-show="activeView === 'todos'" aria-labelledby="todos-heading">
|
|
432
|
+
<header class="report-heading todo-heading">
|
|
433
|
+
<div>
|
|
434
|
+
<h2 id="todos-heading">{{ t('TODOs por proyecto', 'TODOs by project') }}</h2>
|
|
435
|
+
<p class="section-note">{{ t('Tareas persistentes agrupadas por proyecto y estado.', 'Persistent tasks grouped by project and status.') }}</p>
|
|
436
|
+
</div>
|
|
437
|
+
<label class="include-archived">
|
|
438
|
+
<input v-model="includeArchived" type="checkbox">
|
|
439
|
+
<span>{{ t('Incluir archivados', 'Include archived') }}</span>
|
|
440
|
+
</label>
|
|
441
|
+
</header>
|
|
442
|
+
<p v-if="!todoGroups.length" class="empty-report">
|
|
443
|
+
{{ includeArchived ? t('No hay TODOs guardados.', 'No TODOs saved.') : t('No hay TODOs activos. Incluye los archivados para verlos.', 'No active TODOs. Include archived items to see them.') }}
|
|
444
|
+
</p>
|
|
445
|
+
<div v-else class="scope-list">
|
|
446
|
+
<details v-for="group in todoGroups" :key="group.key" class="scope todo-project">
|
|
447
|
+
<summary class="project-summary">
|
|
448
|
+
<div class="scope-provenance">
|
|
449
|
+
<span class="scope-caption">{{ t('Directorio', 'Directory') }} <strong class="scope-directory">{{ projectDirectoryLabel(group, language) }}</strong></span>
|
|
450
|
+
<span class="scope-caption">{{ t('Proyecto', 'Project') }} · {{ group.total }} {{ group.total === 1 ? t('tarea', 'task') : t('tareas', 'tasks') }}</span>
|
|
451
|
+
<code>{{ group.project }}</code>
|
|
452
|
+
</div>
|
|
453
|
+
</summary>
|
|
454
|
+
<div class="todo-status-list">
|
|
455
|
+
<template v-for="status in todoStatuses" :key="status">
|
|
456
|
+
<section v-if="group[status].length" class="todo-status">
|
|
457
|
+
<h3 class="todo-status-heading"><span>{{ todoStatusLabel(status) }}</span><span>{{ group[status].length }}</span></h3>
|
|
458
|
+
<ul class="todo-items">
|
|
459
|
+
<li v-for="todo in group[status]" :key="todo.id">{{ todo.task }}</li>
|
|
460
|
+
</ul>
|
|
461
|
+
</section>
|
|
462
|
+
</template>
|
|
463
|
+
</div>
|
|
464
|
+
</details>
|
|
465
|
+
</div>
|
|
466
|
+
</section>
|
|
467
|
+
</template>
|
|
468
|
+
|
|
469
|
+
<section id="metrics" v-show="activeView === 'metrics'" aria-labelledby="metrics-heading" :aria-busy="metricsLoading">
|
|
470
|
+
<header class="report-heading">
|
|
471
|
+
<div>
|
|
472
|
+
<h2 id="metrics-heading">{{ t('Métricas de uso', 'Usage metrics') }}</h2>
|
|
473
|
+
<p class="section-note">{{ t('Tokens y caché por agente, modelo y proyecto, medidos a partir del uso reportado por el runtime.', 'Tokens and cache by agent, model, and project, measured from usage reported by the runtime.') }}</p>
|
|
474
|
+
</div>
|
|
475
|
+
</header>
|
|
476
|
+
|
|
477
|
+
<fieldset class="metrics-filters">
|
|
478
|
+
<legend>{{ t('Filtros', 'Filters') }}</legend>
|
|
479
|
+
<label class="metrics-filter">
|
|
480
|
+
<span>{{ t('Rango', 'Range') }}</span>
|
|
481
|
+
<select v-model.number="rangeDays">
|
|
482
|
+
<option :value="7">{{ t('7 días', '7 days') }}</option>
|
|
483
|
+
<option :value="30">{{ t('30 días', '30 days') }}</option>
|
|
484
|
+
<option :value="90">{{ t('90 días', '90 days') }}</option>
|
|
485
|
+
</select>
|
|
486
|
+
</label>
|
|
487
|
+
<label class="metrics-filter">
|
|
488
|
+
<span>{{ t('Proyecto', 'Project') }}</span>
|
|
489
|
+
<select v-model="metricProject">
|
|
490
|
+
<option value="">{{ t('Todos los proyectos', 'All projects') }}</option>
|
|
491
|
+
<option v-for="project in metricOptions.projects" :key="project.key" :value="project.key">{{ projectOptionLabel(project) }}</option>
|
|
492
|
+
</select>
|
|
493
|
+
</label>
|
|
494
|
+
<label class="metrics-filter">
|
|
495
|
+
<span>{{ t('Agente', 'Agent') }}</span>
|
|
496
|
+
<select v-model="metricAgent">
|
|
497
|
+
<option value="">{{ t('Todos los agentes', 'All agents') }}</option>
|
|
498
|
+
<option v-for="agent in metricOptions.agents" :key="agent" :value="agent">{{ agent }}</option>
|
|
499
|
+
</select>
|
|
500
|
+
</label>
|
|
501
|
+
<label class="metrics-filter">
|
|
502
|
+
<span>{{ t('Proveedor', 'Provider') }}</span>
|
|
503
|
+
<select v-model="metricProvider">
|
|
504
|
+
<option value="">{{ t('Todos los proveedores', 'All providers') }}</option>
|
|
505
|
+
<option v-for="provider in metricOptions.providers" :key="provider" :value="provider">{{ provider }}</option>
|
|
506
|
+
</select>
|
|
507
|
+
</label>
|
|
508
|
+
<label class="metrics-filter">
|
|
509
|
+
<span>{{ t('Modelo', 'Model') }}</span>
|
|
510
|
+
<select v-model="metricModel">
|
|
511
|
+
<option value="">{{ t('Todos los modelos', 'All models') }}</option>
|
|
512
|
+
<option v-for="model in metricOptions.models" :key="model" :value="model">{{ model }}</option>
|
|
513
|
+
</select>
|
|
514
|
+
</label>
|
|
515
|
+
<button type="button" class="metrics-reset" :disabled="!hasMetricFilters" @click="clearMetricFilters">{{ t('Limpiar filtros', 'Clear filters') }}</button>
|
|
516
|
+
</fieldset>
|
|
517
|
+
|
|
518
|
+
<p v-if="metricsError" class="notice" role="alert">{{ metricsError }} <button type="button" @click="loadMetrics">{{ t('Reintentar', 'Retry') }}</button></p>
|
|
519
|
+
<p v-if="metricsLoading" role="status">{{ t('Leyendo métricas…', 'Reading metrics…') }}</p>
|
|
520
|
+
<p v-else-if="metricsEmpty && !metricsError" class="empty-report metrics-empty">{{ t('La instrumentación empieza ahora. Aún no hay pasos registrados en este rango; el backfill aproximado puede tardar unos segundos.', 'Instrumentation starts now. No steps recorded in this range yet; approximate backfill may take a few seconds.') }}</p>
|
|
521
|
+
|
|
522
|
+
<template v-if="metrics && !metricsEmpty">
|
|
523
|
+
<div class="metrics-cards">
|
|
524
|
+
<article class="metric-card"><span>{{ t('Pasos', 'Steps') }}</span><strong>{{ formatNumber(metrics.totals.steps) }}</strong><small>{{ t('registrados en el rango', 'recorded in range') }}</small></article>
|
|
525
|
+
<article class="metric-card"><span>{{ t('Entrada sin caché', 'Uncached input') }}</span><strong>{{ formatCompact(metrics.totals.tokens.input) }}</strong><small>{{ t('tokens de entrada', 'input tokens') }}</small></article>
|
|
526
|
+
<article class="metric-card"><span>{{ t('Caché leída', 'Cache read') }}</span><strong>{{ formatCompact(metrics.totals.tokens.cacheRead) }}</strong><small>{{ t('tokens reutilizados', 'reused tokens') }}</small></article>
|
|
527
|
+
<article class="metric-card"><span>{{ t('Salida', 'Output') }}</span><strong>{{ formatCompact(metrics.totals.tokens.output) }}</strong><small>{{ t('tokens generados', 'generated tokens') }}</small></article>
|
|
528
|
+
<article class="metric-card"><span>{{ t('Razonamiento', 'Reasoning') }}</span><strong>{{ formatCompact(metrics.totals.tokens.reasoning) }}</strong><small>{{ t('tokens de razonamiento', 'reasoning tokens') }}</small></article>
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
<article class="savings-panel">
|
|
532
|
+
<div>
|
|
533
|
+
<h3>{{ t('Ahorro medido por caché', 'Measured cache savings') }}</h3>
|
|
534
|
+
<p class="section-note">{{ t(`cacheRead / (input + cacheRead + cacheWrite) = ${formatPercent(metrics.totals.cacheHitRate)}. Medición exacta de tokens reutilizados.`, `cacheRead / (input + cacheRead + cacheWrite) = ${formatPercent(metrics.totals.cacheHitRate)}. Exact measurement of reused tokens.`) }}</p>
|
|
535
|
+
</div>
|
|
536
|
+
<strong class="savings-rate">{{ formatPercent(metrics.totals.cacheHitRate) }}</strong>
|
|
537
|
+
<p class="savings-detail">{{ formatCompact(metrics.totals.tokens.cacheRead) }} / {{ formatCompact(cacheDenominator) }} {{ t('tokens de entrada reutilizados', 'input tokens reused') }}</p>
|
|
538
|
+
</article>
|
|
539
|
+
|
|
540
|
+
<div class="metrics-charts">
|
|
541
|
+
<article class="trend-panel">
|
|
542
|
+
<h3>{{ t('Tokens por día · UTC', 'Tokens per day · UTC') }}</h3>
|
|
543
|
+
<ul class="trend-legend" :aria-label="t('Leyenda del gráfico de tokens', 'Token chart legend')">
|
|
544
|
+
<li class="input-series">{{ seriesLabel('input') }}</li>
|
|
545
|
+
<li class="cache-series">{{ seriesLabel('cache') }}</li>
|
|
546
|
+
<li class="output-series">{{ seriesLabel('output') }}</li>
|
|
547
|
+
</ul>
|
|
548
|
+
<p v-if="!tokenChart.length" class="empty-report">{{ t('Sin tokens registrados en este rango.', 'No tokens recorded in this range.') }}</p>
|
|
549
|
+
<template v-else>
|
|
550
|
+
<p class="section-note">{{ t(`Días: ${firstMetricDay} – ${lastMetricDay}.`, `Days: ${firstMetricDay} – ${lastMetricDay}.`) }}</p>
|
|
551
|
+
<svg class="trend-chart metrics-chart" viewBox="0 0 720 180" role="img" aria-labelledby="metrics-tokens-title metrics-tokens-desc">
|
|
552
|
+
<title id="metrics-tokens-title">{{ t('Tokens diarios por serie', 'Daily tokens by series') }}</title>
|
|
553
|
+
<desc id="metrics-tokens-desc">{{ t(`Barras apiladas de entrada sin caché, caché leída y salida para ${metrics.series.length} días UTC.`, `Stacked bars of uncached input, cache read, and output for ${metrics.series.length} UTC days.`) }}</desc>
|
|
554
|
+
<line x1="28" y1="152" x2="704" y2="152" class="chart-axis" />
|
|
555
|
+
<rect v-for="bar in tokenChart" :key="`${bar.day}-${bar.key}`" :class="`${bar.key}-series`"
|
|
556
|
+
:x="bar.x" :y="bar.y" :width="bar.width" :height="bar.height">
|
|
557
|
+
<title>{{ `${bar.day} · ${seriesLabel(bar.key)}: ${formatNumber(bar.value)}` }}</title>
|
|
558
|
+
</rect>
|
|
559
|
+
<text :x="metrics.series.length === 1 ? 366 : 28" y="173" :text-anchor="metrics.series.length === 1 ? 'middle' : 'start'">{{ firstMetricDay }}</text>
|
|
560
|
+
<text v-if="metrics.series.length > 1" x="704" y="173" text-anchor="end">{{ lastMetricDay }}</text>
|
|
561
|
+
</svg>
|
|
562
|
+
</template>
|
|
563
|
+
</article>
|
|
564
|
+
|
|
565
|
+
</div>
|
|
566
|
+
|
|
567
|
+
<p class="section-note metrics-tables-note">{{ t('Máximo 50 filas por dimensión. Ordena por nombre, pasos o tokens desde las cabeceras.', 'Up to 50 rows per dimension. Sort by name, steps, or tokens from the column headers.') }}</p>
|
|
568
|
+
<div class="metrics-tables">
|
|
569
|
+
<article class="metrics-table-panel">
|
|
570
|
+
<table class="metrics-table">
|
|
571
|
+
<caption>{{ t('Por agente', 'By agent') }}</caption>
|
|
572
|
+
<thead><tr>
|
|
573
|
+
<th scope="col" :aria-sort="ariaSort('agent', 'name')">
|
|
574
|
+
<button type="button" class="sort-button" @click="toggleSort('agent', 'name')">{{ t('Agente', 'Agent') }}<span v-if="sortIndicator('agent', 'name')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('agent', 'name') }}</span></button>
|
|
575
|
+
</th>
|
|
576
|
+
<th scope="col" :aria-sort="ariaSort('agent', 'steps')">
|
|
577
|
+
<button type="button" class="sort-button" @click="toggleSort('agent', 'steps')">{{ t('Pasos', 'Steps') }}<span v-if="sortIndicator('agent', 'steps')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('agent', 'steps') }}</span></button>
|
|
578
|
+
</th>
|
|
579
|
+
<th scope="col" :aria-sort="ariaSort('agent', 'tokens')">
|
|
580
|
+
<button type="button" class="sort-button" @click="toggleSort('agent', 'tokens')">{{ t('Tokens', 'Tokens') }}<span v-if="sortIndicator('agent', 'tokens')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('agent', 'tokens') }}</span></button>
|
|
581
|
+
</th>
|
|
582
|
+
</tr></thead>
|
|
583
|
+
<tbody>
|
|
584
|
+
<tr v-for="row in agentRows" :key="row.key ?? 'none'">
|
|
585
|
+
<th scope="row">{{ row.key || t('Sin agente', 'No agent') }}</th>
|
|
586
|
+
<td class="numeric">{{ formatNumber(row.steps) }}</td>
|
|
587
|
+
<td>{{ tokenBreakdown(row) }}</td>
|
|
588
|
+
</tr>
|
|
589
|
+
</tbody>
|
|
590
|
+
</table>
|
|
591
|
+
<p v-if="!metrics.byAgent.length" class="empty-report">{{ t('Sin datos por agente en este rango.', 'No agent data in this range.') }}</p>
|
|
592
|
+
</article>
|
|
593
|
+
|
|
594
|
+
<article class="metrics-table-panel">
|
|
595
|
+
<table class="metrics-table">
|
|
596
|
+
<caption>{{ t('Por modelo y proveedor', 'By model and provider') }}</caption>
|
|
597
|
+
<thead><tr>
|
|
598
|
+
<th scope="col" :aria-sort="ariaSort('model', 'name')">
|
|
599
|
+
<button type="button" class="sort-button" @click="toggleSort('model', 'name')">{{ t('Modelo / proveedor', 'Model / provider') }}<span v-if="sortIndicator('model', 'name')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('model', 'name') }}</span></button>
|
|
600
|
+
</th>
|
|
601
|
+
<th scope="col" :aria-sort="ariaSort('model', 'steps')">
|
|
602
|
+
<button type="button" class="sort-button" @click="toggleSort('model', 'steps')">{{ t('Pasos', 'Steps') }}<span v-if="sortIndicator('model', 'steps')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('model', 'steps') }}</span></button>
|
|
603
|
+
</th>
|
|
604
|
+
<th scope="col" :aria-sort="ariaSort('model', 'tokens')">
|
|
605
|
+
<button type="button" class="sort-button" @click="toggleSort('model', 'tokens')">{{ t('Tokens', 'Tokens') }}<span v-if="sortIndicator('model', 'tokens')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('model', 'tokens') }}</span></button>
|
|
606
|
+
</th>
|
|
607
|
+
</tr></thead>
|
|
608
|
+
<tbody>
|
|
609
|
+
<tr v-for="row in modelRows" :key="`${row.provider ?? ''}:${row.key ?? ''}`">
|
|
610
|
+
<th scope="row"><span>{{ row.key || t('Sin modelo', 'No model') }}</span><small class="row-sub">{{ row.provider || t('Sin proveedor', 'No provider') }}</small></th>
|
|
611
|
+
<td class="numeric">{{ formatNumber(row.steps) }}</td>
|
|
612
|
+
<td>{{ tokenBreakdown(row) }}</td>
|
|
613
|
+
</tr>
|
|
614
|
+
</tbody>
|
|
615
|
+
</table>
|
|
616
|
+
<p v-if="!metrics.byModel.length" class="empty-report">{{ t('Sin datos por modelo en este rango.', 'No model data in this range.') }}</p>
|
|
617
|
+
</article>
|
|
618
|
+
|
|
619
|
+
<article class="metrics-table-panel">
|
|
620
|
+
<table class="metrics-table">
|
|
621
|
+
<caption>{{ t('Por proyecto', 'By project') }}</caption>
|
|
622
|
+
<thead><tr>
|
|
623
|
+
<th scope="col" :aria-sort="ariaSort('project', 'name')">
|
|
624
|
+
<button type="button" class="sort-button" @click="toggleSort('project', 'name')">{{ t('Proyecto / directorio', 'Project / directory') }}<span v-if="sortIndicator('project', 'name')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('project', 'name') }}</span></button>
|
|
625
|
+
</th>
|
|
626
|
+
<th scope="col" :aria-sort="ariaSort('project', 'steps')">
|
|
627
|
+
<button type="button" class="sort-button" @click="toggleSort('project', 'steps')">{{ t('Pasos', 'Steps') }}<span v-if="sortIndicator('project', 'steps')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('project', 'steps') }}</span></button>
|
|
628
|
+
</th>
|
|
629
|
+
<th scope="col" :aria-sort="ariaSort('project', 'tokens')">
|
|
630
|
+
<button type="button" class="sort-button" @click="toggleSort('project', 'tokens')">{{ t('Tokens', 'Tokens') }}<span v-if="sortIndicator('project', 'tokens')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('project', 'tokens') }}</span></button>
|
|
631
|
+
</th>
|
|
632
|
+
</tr></thead>
|
|
633
|
+
<tbody>
|
|
634
|
+
<tr v-for="row in projectRows" :key="row.key ?? 'none'">
|
|
635
|
+
<th scope="row"><code class="project-hash">{{ row.key || '—' }}</code><small class="row-sub">{{ row.directory || t('Directorio no disponible', 'Directory unavailable') }}</small></th>
|
|
636
|
+
<td class="numeric">{{ formatNumber(row.steps) }}</td>
|
|
637
|
+
<td>{{ tokenBreakdown(row) }}</td>
|
|
638
|
+
</tr>
|
|
639
|
+
</tbody>
|
|
640
|
+
</table>
|
|
641
|
+
<p v-if="!metrics.byProject.length" class="empty-report">{{ t('Sin datos por proyecto en este rango.', 'No project data in this range.') }}</p>
|
|
642
|
+
</article>
|
|
643
|
+
|
|
644
|
+
<article class="metrics-table-panel">
|
|
645
|
+
<table class="metrics-table">
|
|
646
|
+
<caption>{{ t('Por tipo de petición', 'By request type') }}</caption>
|
|
647
|
+
<thead><tr>
|
|
648
|
+
<th scope="col" :aria-sort="ariaSort('kind', 'name')">
|
|
649
|
+
<button type="button" class="sort-button" @click="toggleSort('kind', 'name')">{{ t('Tipo', 'Type') }}<span v-if="sortIndicator('kind', 'name')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('kind', 'name') }}</span></button>
|
|
650
|
+
</th>
|
|
651
|
+
<th scope="col" :aria-sort="ariaSort('kind', 'steps')">
|
|
652
|
+
<button type="button" class="sort-button" @click="toggleSort('kind', 'steps')">{{ t('Pasos', 'Steps') }}<span v-if="sortIndicator('kind', 'steps')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('kind', 'steps') }}</span></button>
|
|
653
|
+
</th>
|
|
654
|
+
<th scope="col" :aria-sort="ariaSort('kind', 'tokens')">
|
|
655
|
+
<button type="button" class="sort-button" @click="toggleSort('kind', 'tokens')">{{ t('Tokens', 'Tokens') }}<span v-if="sortIndicator('kind', 'tokens')" class="sort-indicator" aria-hidden="true">{{ sortIndicator('kind', 'tokens') }}</span></button>
|
|
656
|
+
</th>
|
|
657
|
+
</tr></thead>
|
|
658
|
+
<tbody>
|
|
659
|
+
<tr v-for="row in kindRows" :key="row.key">
|
|
660
|
+
<th scope="row">{{ metricKindLabel(row.key) }}</th>
|
|
661
|
+
<td class="numeric">{{ formatNumber(row.steps) }}</td>
|
|
662
|
+
<td>{{ tokenBreakdown(row) }}</td>
|
|
663
|
+
</tr>
|
|
664
|
+
</tbody>
|
|
665
|
+
</table>
|
|
666
|
+
<p v-if="!metrics.byKind.length" class="empty-report">{{ t('Sin datos por tipo en este rango.', 'No type data in this range.') }}</p>
|
|
667
|
+
</article>
|
|
668
|
+
</div>
|
|
669
|
+
</template>
|
|
670
|
+
|
|
671
|
+
<section class="plugin-health" aria-labelledby="plugin-health-heading">
|
|
672
|
+
<header class="plugin-heading">
|
|
673
|
+
<div>
|
|
674
|
+
<h3 id="plugin-health-heading">{{ t('Salud del plugin', 'Plugin health') }}</h3>
|
|
675
|
+
<p class="section-note">{{ t('Contadores en memoria de esta sesión del plugin; no se guardan en store-v1.json y no incluyen prompts, rutas, argumentos ni IDs de sesión.', 'In-memory counters for this plugin session; they are not saved in store-v1.json and contain no prompts, paths, arguments, or session IDs.') }}</p>
|
|
676
|
+
</div>
|
|
677
|
+
</header>
|
|
678
|
+
<p v-if="pluginEmpty && !metricsLoading && !metricsError" class="empty-report">{{ t('Aún no hay actividad interna registrada. Los contadores aparecen cuando el plugin procesa hooks, storage o SSE.', 'No internal activity recorded yet. Counters appear when the plugin handles hooks, storage, or SSE.') }}</p>
|
|
679
|
+
<template v-else-if="!pluginEmpty">
|
|
680
|
+
<div class="metrics-cards">
|
|
681
|
+
<article class="metric-card"><span>{{ t('Hooks', 'Hooks') }}</span><strong>{{ formatNumber(pluginHookCalls) }}</strong><small>{{ t('llamadas', 'calls') }} · {{ formatNumber(pluginHookErrors) }} {{ t('errores', 'errors') }}</small></article>
|
|
682
|
+
<article class="metric-card"><span>{{ t('Almacenamiento', 'Storage') }}</span><strong>{{ formatNumber(plugin.store.reads) }} / {{ formatNumber(plugin.store.writes) }}</strong><small>{{ t('lecturas / escrituras', 'reads / writes') }}</small></article>
|
|
683
|
+
<article class="metric-card"><span>{{ t('Contexto inyectado', 'Injected context') }}</span><strong>{{ formatNumber(plugin.context.injectedChars) }}</strong><small>{{ t('caracteres', 'characters') }} · {{ formatNumber(plugin.context.injections) }} {{ t('inyecciones', 'injections') }}</small></article>
|
|
684
|
+
<article class="metric-card"><span>{{ t('SSE', 'SSE') }}</span><strong>{{ formatNumber(plugin.sse.clients) }}</strong><small>{{ t('clientes · pico', 'clients · peak') }} {{ formatNumber(plugin.sse.peakClients) }}</small></article>
|
|
685
|
+
<article class="metric-card"><span>{{ t('Peticiones 2xx', '2xx requests') }}</span><strong>{{ formatNumber(plugin.requests['2xx']) }}</strong><small>4xx {{ formatNumber(plugin.requests['4xx']) }} · 5xx {{ formatNumber(plugin.requests['5xx']) }}</small></article>
|
|
686
|
+
<article class="metric-card"><span>{{ t('Errores', 'Errors') }}</span><strong>{{ formatNumber(pluginErrorTotal) }}</strong><small>{{ t('contabilizados', 'counted') }}</small></article>
|
|
687
|
+
</div>
|
|
688
|
+
|
|
689
|
+
<div class="metrics-tables plugin-tables">
|
|
690
|
+
<article class="metrics-table-panel">
|
|
691
|
+
<table class="metrics-table">
|
|
692
|
+
<caption>{{ t('Hooks del plugin', 'Plugin hooks') }}</caption>
|
|
693
|
+
<thead><tr><th scope="col">{{ t('Hook', 'Hook') }}</th><th scope="col">{{ t('Llamadas', 'Calls') }}</th><th scope="col">{{ t('Errores', 'Errors') }}</th><th scope="col">p50</th><th scope="col">p95</th></tr></thead>
|
|
694
|
+
<tbody>
|
|
695
|
+
<tr v-for="hook in pluginHooks" :key="hook.name">
|
|
696
|
+
<th scope="row">{{ pluginHookLabel(hook.name) }}</th>
|
|
697
|
+
<td class="numeric">{{ formatNumber(hook.invocations) }}</td>
|
|
698
|
+
<td class="numeric">{{ formatNumber(hook.errors) }}</td>
|
|
699
|
+
<td class="numeric">{{ formatMillis(hook.p50) }}</td>
|
|
700
|
+
<td class="numeric">{{ formatMillis(hook.p95) }}</td>
|
|
701
|
+
</tr>
|
|
702
|
+
</tbody>
|
|
703
|
+
</table>
|
|
704
|
+
</article>
|
|
705
|
+
|
|
706
|
+
<article class="metrics-table-panel">
|
|
707
|
+
<table class="metrics-table">
|
|
708
|
+
<caption>{{ t('Almacenamiento y memoria', 'Storage and memory') }}</caption>
|
|
709
|
+
<thead><tr><th scope="col">{{ t('Métrica', 'Metric') }}</th><th scope="col">{{ t('Valor', 'Value') }}</th></tr></thead>
|
|
710
|
+
<tbody>
|
|
711
|
+
<tr><th scope="row">{{ t('Bytes leídos / escritos', 'Bytes read / written') }}</th><td class="numeric">{{ formatBytes(plugin.store.bytesRead) }} / {{ formatBytes(plugin.store.bytesWritten) }}</td></tr>
|
|
712
|
+
<tr><th scope="row">{{ t('Esperas / timeouts de lock', 'Lock waits / timeouts') }}</th><td class="numeric">{{ formatNumber(plugin.store.lockWaits) }} / {{ formatNumber(plugin.store.lockTimeouts) }}</td></tr>
|
|
713
|
+
<tr><th scope="row">{{ t('Latencia de escritura p50 / p95', 'Write latency p50 / p95') }}</th><td class="numeric">{{ formatMillis(plugin.store.writeLatency.p50) }} / {{ formatMillis(plugin.store.writeLatency.p95) }}</td></tr>
|
|
714
|
+
<tr><th scope="row">{{ t('Tamaño store / metrics', 'Store / metrics size') }}</th><td class="numeric">{{ formatBytes(plugin.bytes.store) }} / {{ formatBytes(plugin.bytes.metrics) }}</td></tr>
|
|
715
|
+
<tr><th scope="row">{{ t('Avisos checkpoint / TODO', 'Checkpoint / TODO notices') }}</th><td class="numeric">{{ formatNumber(plugin.context.notices.checkpoint) }} / {{ formatNumber(plugin.context.notices.todo) }}</td></tr>
|
|
716
|
+
</tbody>
|
|
717
|
+
</table>
|
|
718
|
+
</article>
|
|
719
|
+
|
|
720
|
+
<article class="metrics-table-panel">
|
|
721
|
+
<table class="metrics-table">
|
|
722
|
+
<caption>{{ t('SSE, peticiones y flush', 'SSE, requests, and flush') }}</caption>
|
|
723
|
+
<thead><tr><th scope="col">{{ t('Métrica', 'Metric') }}</th><th scope="col">{{ t('Valor', 'Value') }}</th></tr></thead>
|
|
724
|
+
<tbody>
|
|
725
|
+
<tr><th scope="row">{{ t('Clientes SSE / pico', 'SSE clients / peak') }}</th><td class="numeric">{{ formatNumber(plugin.sse.clients) }} / {{ formatNumber(plugin.sse.peakClients) }}</td></tr>
|
|
726
|
+
<tr><th scope="row">{{ t('Refrescos SSE', 'SSE refreshes') }}</th><td class="numeric">{{ formatNumber(plugin.sse.refreshes) }}</td></tr>
|
|
727
|
+
<tr><th scope="row">{{ t('Peticiones 2xx / 4xx / 5xx', 'Requests 2xx / 4xx / 5xx') }}</th><td class="numeric">{{ formatNumber(plugin.requests['2xx']) }} / {{ formatNumber(plugin.requests['4xx']) }} / {{ formatNumber(plugin.requests['5xx']) }}</td></tr>
|
|
728
|
+
<tr><th scope="row">{{ t('Último flush', 'Last flush') }}</th><td class="numeric">{{ formatTime(plugin.flush.lastAt) }}</td></tr>
|
|
729
|
+
<tr><th scope="row">{{ t('Pendientes / fallos de flush', 'Flush pending / failures') }}</th><td class="numeric">{{ formatNumber(plugin.flush.pending) }} / {{ formatNumber(plugin.flush.failures) }}</td></tr>
|
|
730
|
+
</tbody>
|
|
731
|
+
</table>
|
|
732
|
+
</article>
|
|
733
|
+
|
|
734
|
+
<article class="metrics-table-panel">
|
|
735
|
+
<table class="metrics-table">
|
|
736
|
+
<caption>{{ t('Errores y estado actual', 'Errors and current state') }}</caption>
|
|
737
|
+
<thead><tr><th scope="col">{{ t('Categoría', 'Category') }}</th><th scope="col">{{ t('Valor', 'Value') }}</th></tr></thead>
|
|
738
|
+
<tbody>
|
|
739
|
+
<tr v-for="error in pluginErrors" :key="error.key"><th scope="row">{{ pluginErrorLabel(error.key) }}</th><td class="numeric">{{ formatNumber(error.count) }}</td></tr>
|
|
740
|
+
<tr><th scope="row">{{ t('Checkpoints / TODOs / ámbitos', 'Checkpoints / TODOs / scopes') }}</th><td class="numeric">{{ pluginCurrent ? `${formatNumber(pluginCurrent.checkpoints)} / ${formatNumber(pluginCurrent.todos)} / ${formatNumber(pluginCurrent.scopes)}` : '—' }}</td></tr>
|
|
741
|
+
<tr><th scope="row">{{ t('TODOs activos', 'Active TODOs') }}</th><td class="numeric">{{ pluginCurrent ? formatNumber(pluginCurrent.activeTodos) : '—' }}</td></tr>
|
|
742
|
+
<tr><th scope="row">{{ t('Activo desde', 'Active since') }}</th><td class="numeric">{{ formatTime(plugin.startedAt) }}</td></tr>
|
|
743
|
+
</tbody>
|
|
744
|
+
</table>
|
|
745
|
+
</article>
|
|
746
|
+
</div>
|
|
747
|
+
</template>
|
|
748
|
+
</section>
|
|
749
|
+
</section>
|
|
750
|
+
</main>
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
</template>
|