memorix 0.5.0 → 0.5.2
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/README.md +8 -6
- package/dist/cli/index.js +333 -15
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/static/app.js +921 -0
- package/dist/dashboard/static/index.html +85 -0
- package/dist/dashboard/static/logo.png +0 -0
- package/dist/dashboard/static/style.css +1048 -0
- package/dist/index.js +372 -91
- package/dist/index.js.map +1 -1
- package/package.json +1 -2
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memorix Dashboard — SPA Application
|
|
3
|
+
* Vanilla JS, zero dependencies, i18n support (EN/ZH)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ============================================================
|
|
7
|
+
// i18n — Internationalization
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
const i18n = {
|
|
11
|
+
en: {
|
|
12
|
+
// Dashboard
|
|
13
|
+
dashboard: 'Dashboard',
|
|
14
|
+
dashboardSubtitle: 'Overview of your project memory',
|
|
15
|
+
entities: 'Entities',
|
|
16
|
+
relations: 'Relations',
|
|
17
|
+
observations: 'Observations',
|
|
18
|
+
nextId: 'Next ID',
|
|
19
|
+
observationTypes: 'Observation Types',
|
|
20
|
+
recentActivity: 'Recent Activity',
|
|
21
|
+
noObservationsYet: 'No observations yet',
|
|
22
|
+
noRecentActivity: 'No recent activity',
|
|
23
|
+
noData: 'No Data',
|
|
24
|
+
noDataDesc: 'Start using Memorix to see your dashboard',
|
|
25
|
+
|
|
26
|
+
// Graph
|
|
27
|
+
knowledgeGraph: 'Knowledge Graph',
|
|
28
|
+
noGraphData: 'No Graph Data',
|
|
29
|
+
noGraphDataDesc: 'Create entities and relations to see your knowledge graph',
|
|
30
|
+
observation_s: 'observation(s)',
|
|
31
|
+
nodes: 'nodes',
|
|
32
|
+
edges: 'edges',
|
|
33
|
+
clickNodeToView: 'Click a node to view details',
|
|
34
|
+
noObservations: 'No observations',
|
|
35
|
+
noRelations: 'No relations',
|
|
36
|
+
|
|
37
|
+
// Observations
|
|
38
|
+
observationsStored: 'observations stored',
|
|
39
|
+
searchObservations: 'Search observations...',
|
|
40
|
+
all: 'All',
|
|
41
|
+
noMatchingObs: 'No matching observations',
|
|
42
|
+
noObsTitle: 'No Observations',
|
|
43
|
+
noObsDesc: 'Use memorix_store to create observations',
|
|
44
|
+
untitled: 'Untitled',
|
|
45
|
+
|
|
46
|
+
// Retention
|
|
47
|
+
memoryRetention: 'Memory Retention',
|
|
48
|
+
retentionSubtitle: 'Exponential decay scoring with immunity rules',
|
|
49
|
+
active: 'Active',
|
|
50
|
+
stale: 'Stale',
|
|
51
|
+
archiveCandidates: 'Archive Candidates',
|
|
52
|
+
immune: 'Immune',
|
|
53
|
+
allObsByScore: 'All Observations by Retention Score',
|
|
54
|
+
id: 'ID',
|
|
55
|
+
title: 'Title',
|
|
56
|
+
type: 'Type',
|
|
57
|
+
entity: 'Entity',
|
|
58
|
+
score: 'Score',
|
|
59
|
+
ageH: 'Age (h)',
|
|
60
|
+
access: 'Access',
|
|
61
|
+
status: 'Status',
|
|
62
|
+
noRetentionData: 'No Retention Data',
|
|
63
|
+
noRetentionDesc: 'Store observations to see memory retention scores',
|
|
64
|
+
|
|
65
|
+
// Nav tooltips
|
|
66
|
+
navDashboard: 'Dashboard',
|
|
67
|
+
navGraph: 'Knowledge Graph',
|
|
68
|
+
navObservations: 'Observations',
|
|
69
|
+
navRetention: 'Retention',
|
|
70
|
+
},
|
|
71
|
+
zh: {
|
|
72
|
+
// Dashboard
|
|
73
|
+
dashboard: '仪表盘',
|
|
74
|
+
dashboardSubtitle: '项目记忆概览',
|
|
75
|
+
entities: '实体',
|
|
76
|
+
relations: '关系',
|
|
77
|
+
observations: '观察记录',
|
|
78
|
+
nextId: '下一个 ID',
|
|
79
|
+
observationTypes: '观察类型分布',
|
|
80
|
+
recentActivity: '最近活动',
|
|
81
|
+
noObservationsYet: '暂无观察记录',
|
|
82
|
+
noRecentActivity: '暂无最近活动',
|
|
83
|
+
noData: '暂无数据',
|
|
84
|
+
noDataDesc: '开始使用 Memorix 来查看仪表盘',
|
|
85
|
+
|
|
86
|
+
// Graph
|
|
87
|
+
knowledgeGraph: '知识图谱',
|
|
88
|
+
noGraphData: '暂无图谱数据',
|
|
89
|
+
noGraphDataDesc: '创建实体和关系来查看知识图谱',
|
|
90
|
+
observation_s: '条观察',
|
|
91
|
+
nodes: '个节点',
|
|
92
|
+
edges: '条边',
|
|
93
|
+
clickNodeToView: '点击节点查看详情',
|
|
94
|
+
noObservations: '暂无观察',
|
|
95
|
+
noRelations: '暂无关系',
|
|
96
|
+
|
|
97
|
+
// Observations
|
|
98
|
+
observationsStored: '条观察已存储',
|
|
99
|
+
searchObservations: '搜索观察记录...',
|
|
100
|
+
all: '全部',
|
|
101
|
+
noMatchingObs: '没有匹配的观察记录',
|
|
102
|
+
noObsTitle: '暂无观察记录',
|
|
103
|
+
noObsDesc: '使用 memorix_store 创建观察记录',
|
|
104
|
+
untitled: '无标题',
|
|
105
|
+
|
|
106
|
+
// Retention
|
|
107
|
+
memoryRetention: '记忆衰减',
|
|
108
|
+
retentionSubtitle: '基于指数衰减的评分系统,支持免疫规则',
|
|
109
|
+
active: '活跃',
|
|
110
|
+
stale: '陈旧',
|
|
111
|
+
archiveCandidates: '归档候选',
|
|
112
|
+
immune: '免疫',
|
|
113
|
+
allObsByScore: '按衰减分数排列的所有观察',
|
|
114
|
+
id: 'ID',
|
|
115
|
+
title: '标题',
|
|
116
|
+
type: '类型',
|
|
117
|
+
entity: '实体',
|
|
118
|
+
score: '分数',
|
|
119
|
+
ageH: '年龄 (h)',
|
|
120
|
+
access: '访问次数',
|
|
121
|
+
status: '状态',
|
|
122
|
+
noRetentionData: '暂无衰减数据',
|
|
123
|
+
noRetentionDesc: '存储观察记录以查看记忆衰减分数',
|
|
124
|
+
|
|
125
|
+
// Nav tooltips
|
|
126
|
+
navDashboard: '仪表盘',
|
|
127
|
+
navGraph: '知识图谱',
|
|
128
|
+
navObservations: '观察记录',
|
|
129
|
+
navRetention: '记忆衰减',
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
let currentLang = localStorage.getItem('memorix-lang') || 'en';
|
|
134
|
+
|
|
135
|
+
function t(key) {
|
|
136
|
+
return (i18n[currentLang] && i18n[currentLang][key]) || i18n.en[key] || key;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function setLang(lang) {
|
|
140
|
+
currentLang = lang;
|
|
141
|
+
localStorage.setItem('memorix-lang', lang);
|
|
142
|
+
|
|
143
|
+
// Update button text
|
|
144
|
+
const btn = document.getElementById('lang-toggle');
|
|
145
|
+
if (btn) btn.textContent = lang === 'en' ? '中' : 'EN';
|
|
146
|
+
|
|
147
|
+
// Update nav tooltips
|
|
148
|
+
const tooltipMap = { dashboard: 'navDashboard', graph: 'navGraph', observations: 'navObservations', retention: 'navRetention' };
|
|
149
|
+
document.querySelectorAll('.nav-btn').forEach(b => {
|
|
150
|
+
const page = b.dataset.page;
|
|
151
|
+
if (page && tooltipMap[page]) b.title = t(tooltipMap[page]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Force reload all pages
|
|
155
|
+
Object.keys(loaded).forEach(k => delete loaded[k]);
|
|
156
|
+
loadPage(currentPage);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Init lang toggle button
|
|
160
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
161
|
+
const btn = document.getElementById('lang-toggle');
|
|
162
|
+
if (btn) {
|
|
163
|
+
btn.textContent = currentLang === 'en' ? '中' : 'EN';
|
|
164
|
+
btn.addEventListener('click', () => {
|
|
165
|
+
setLang(currentLang === 'en' ? 'zh' : 'en');
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ============================================================
|
|
171
|
+
// Theme Toggle (Light / Dark)
|
|
172
|
+
// ============================================================
|
|
173
|
+
|
|
174
|
+
let currentTheme = localStorage.getItem('memorix-theme') || 'dark';
|
|
175
|
+
|
|
176
|
+
function applyTheme(theme) {
|
|
177
|
+
currentTheme = theme;
|
|
178
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
179
|
+
localStorage.setItem('memorix-theme', theme);
|
|
180
|
+
|
|
181
|
+
const sunIcon = document.getElementById('theme-icon-sun');
|
|
182
|
+
const moonIcon = document.getElementById('theme-icon-moon');
|
|
183
|
+
if (sunIcon && moonIcon) {
|
|
184
|
+
// Show sun icon in dark mode (click to go light), moon in light mode (click to go dark)
|
|
185
|
+
sunIcon.style.display = theme === 'dark' ? 'none' : 'block';
|
|
186
|
+
moonIcon.style.display = theme === 'dark' ? 'block' : 'none';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Force reload current page so Canvas graph redraws with new colors
|
|
190
|
+
try {
|
|
191
|
+
if (typeof currentPage !== 'undefined' && loaded[currentPage]) {
|
|
192
|
+
delete loaded[currentPage];
|
|
193
|
+
loadPage(currentPage);
|
|
194
|
+
}
|
|
195
|
+
} catch { /* initial call before loaded is defined */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Apply saved theme immediately
|
|
199
|
+
applyTheme(currentTheme);
|
|
200
|
+
|
|
201
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
202
|
+
const themeBtn = document.getElementById('theme-toggle');
|
|
203
|
+
if (themeBtn) {
|
|
204
|
+
themeBtn.addEventListener('click', () => {
|
|
205
|
+
applyTheme(currentTheme === 'dark' ? 'light' : 'dark');
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ============================================================
|
|
211
|
+
// Router & Navigation
|
|
212
|
+
// ============================================================
|
|
213
|
+
|
|
214
|
+
const pages = ['dashboard', 'graph', 'observations', 'retention'];
|
|
215
|
+
let currentPage = 'dashboard';
|
|
216
|
+
|
|
217
|
+
function navigate(page) {
|
|
218
|
+
if (!pages.includes(page)) return;
|
|
219
|
+
currentPage = page;
|
|
220
|
+
|
|
221
|
+
// Update nav
|
|
222
|
+
document.querySelectorAll('.nav-btn').forEach(btn => {
|
|
223
|
+
btn.classList.toggle('active', btn.dataset.page === page);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Update pages
|
|
227
|
+
document.querySelectorAll('.page').forEach(p => {
|
|
228
|
+
p.classList.toggle('active', p.id === `page-${page}`);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Load page data
|
|
232
|
+
loadPage(page);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Nav click handlers
|
|
236
|
+
document.querySelectorAll('.nav-btn').forEach(btn => {
|
|
237
|
+
btn.addEventListener('click', () => navigate(btn.dataset.page));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ============================================================
|
|
241
|
+
// API Client
|
|
242
|
+
// ============================================================
|
|
243
|
+
|
|
244
|
+
async function api(endpoint) {
|
|
245
|
+
try {
|
|
246
|
+
const res = await fetch(`/api/${endpoint}`);
|
|
247
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
248
|
+
return await res.json();
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.error(`API error (${endpoint}):`, err);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ============================================================
|
|
256
|
+
// Page Loaders
|
|
257
|
+
// ============================================================
|
|
258
|
+
|
|
259
|
+
const loaded = {};
|
|
260
|
+
|
|
261
|
+
async function loadPage(page) {
|
|
262
|
+
if (loaded[page]) return;
|
|
263
|
+
|
|
264
|
+
switch (page) {
|
|
265
|
+
case 'dashboard': await loadDashboard(); break;
|
|
266
|
+
case 'graph': await loadGraph(); break;
|
|
267
|
+
case 'observations': await loadObservations(); break;
|
|
268
|
+
case 'retention': await loadRetention(); break;
|
|
269
|
+
}
|
|
270
|
+
loaded[page] = true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ============================================================
|
|
274
|
+
// Dashboard Page
|
|
275
|
+
// ============================================================
|
|
276
|
+
|
|
277
|
+
async function loadDashboard() {
|
|
278
|
+
const container = document.getElementById('page-dashboard');
|
|
279
|
+
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
|
280
|
+
|
|
281
|
+
const [stats, project] = await Promise.all([api('stats'), api('project')]);
|
|
282
|
+
if (!stats) {
|
|
283
|
+
container.innerHTML = emptyState('📊', t('noData'), t('noDataDesc'));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const projectLabel = project ? project.name : '';
|
|
288
|
+
|
|
289
|
+
const typeIcons = {
|
|
290
|
+
'session-request': '🎯', gotcha: '🔴', 'problem-solution': '🟡',
|
|
291
|
+
'how-it-works': '🔵', 'what-changed': '🟢', discovery: '🟣',
|
|
292
|
+
'why-it-exists': '🟠', decision: '🟤', 'trade-off': '⚖️',
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Type distribution
|
|
296
|
+
const typeEntries = Object.entries(stats.typeCounts || {}).sort((a, b) => b[1] - a[1]);
|
|
297
|
+
const maxTypeCount = Math.max(...typeEntries.map(e => e[1]), 1);
|
|
298
|
+
|
|
299
|
+
container.innerHTML = `
|
|
300
|
+
<div class="page-header">
|
|
301
|
+
<h1 class="page-title">${t('dashboard')} ${projectLabel ? `<span style="font-size: 14px; font-weight: 400; color: var(--text-muted); margin-left: 8px; padding: 2px 10px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: 6px; vertical-align: middle;">${escapeHtml(projectLabel)}</span>` : ''}</h1>
|
|
302
|
+
<p class="page-subtitle">${t('dashboardSubtitle')}</p>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<div class="stats-grid">
|
|
306
|
+
<div class="stat-card" data-accent="cyan">
|
|
307
|
+
<div class="stat-label">${t('entities')}</div>
|
|
308
|
+
<div class="stat-value">${stats.entities}</div>
|
|
309
|
+
</div>
|
|
310
|
+
<div class="stat-card" data-accent="purple">
|
|
311
|
+
<div class="stat-label">${t('relations')}</div>
|
|
312
|
+
<div class="stat-value">${stats.relations}</div>
|
|
313
|
+
</div>
|
|
314
|
+
<div class="stat-card" data-accent="amber">
|
|
315
|
+
<div class="stat-label">${t('observations')}</div>
|
|
316
|
+
<div class="stat-value">${stats.observations}</div>
|
|
317
|
+
</div>
|
|
318
|
+
<div class="stat-card" data-accent="green">
|
|
319
|
+
<div class="stat-label">${t('nextId')}</div>
|
|
320
|
+
<div class="stat-value">#${stats.nextId}</div>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
|
325
|
+
<div class="panel">
|
|
326
|
+
<div class="panel-header">
|
|
327
|
+
<span class="panel-title">${t('observationTypes')}</span>
|
|
328
|
+
</div>
|
|
329
|
+
<div class="panel-body">
|
|
330
|
+
${typeEntries.length > 0 ? typeEntries.map(([type, count]) => `
|
|
331
|
+
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
|
332
|
+
<span style="width: 20px; text-align: center;">${typeIcons[type] || '❓'}</span>
|
|
333
|
+
<span style="width: 120px; font-size: 12px; color: var(--text-secondary);">${type}</span>
|
|
334
|
+
<div style="flex: 1; height: 6px; background: rgba(255,255,255,0.04); border-radius: 3px; overflow: hidden;">
|
|
335
|
+
<div style="width: ${(count / maxTypeCount) * 100}%; height: 100%; background: var(--type-${type}, var(--accent-cyan)); border-radius: 3px;"></div>
|
|
336
|
+
</div>
|
|
337
|
+
<span style="font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); min-width: 24px; text-align: right;">${count}</span>
|
|
338
|
+
</div>
|
|
339
|
+
`).join('') : `<p style="color: var(--text-muted); font-size: 13px;">${t('noObservationsYet')}</p>`}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<div class="panel">
|
|
344
|
+
<div class="panel-header">
|
|
345
|
+
<span class="panel-title">${t('recentActivity')}</span>
|
|
346
|
+
</div>
|
|
347
|
+
<div class="panel-body">
|
|
348
|
+
<ul class="activity-list">
|
|
349
|
+
${(stats.recentObservations || []).map(obs => `
|
|
350
|
+
<li class="activity-item">
|
|
351
|
+
<span class="activity-id">#${obs.id}</span>
|
|
352
|
+
<span class="type-badge" data-type="${obs.type}">
|
|
353
|
+
<span class="type-icon" data-type="${obs.type}"></span>
|
|
354
|
+
${obs.type}
|
|
355
|
+
</span>
|
|
356
|
+
<span class="activity-title">${escapeHtml(obs.title || t('untitled'))}</span>
|
|
357
|
+
<span class="activity-entity">${escapeHtml(obs.entityName || '')}</span>
|
|
358
|
+
</li>
|
|
359
|
+
`).join('')}
|
|
360
|
+
</ul>
|
|
361
|
+
${(stats.recentObservations || []).length === 0 ? `<p style="color: var(--text-muted); font-size: 13px; padding: 12px 0;">${t('noRecentActivity')}</p>` : ''}
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ============================================================
|
|
369
|
+
// Knowledge Graph Page
|
|
370
|
+
// ============================================================
|
|
371
|
+
|
|
372
|
+
async function loadGraph() {
|
|
373
|
+
const container = document.getElementById('page-graph');
|
|
374
|
+
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
|
375
|
+
|
|
376
|
+
const graph = await api('graph');
|
|
377
|
+
if (!graph || (graph.entities.length === 0 && graph.relations.length === 0)) {
|
|
378
|
+
container.innerHTML = emptyState('🕸️', t('noGraphData'), t('noGraphDataDesc'));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
container.innerHTML = `
|
|
383
|
+
<div class="page-header">
|
|
384
|
+
<h1 class="page-title">${t('knowledgeGraph')}</h1>
|
|
385
|
+
<p class="page-subtitle">${graph.entities.length} ${t('entities').toLowerCase()}, ${graph.relations.length} ${t('relations').toLowerCase()}</p>
|
|
386
|
+
</div>
|
|
387
|
+
<div class="graph-layout">
|
|
388
|
+
<div id="graph-container">
|
|
389
|
+
<canvas id="graph-canvas"></canvas>
|
|
390
|
+
<div class="graph-tooltip" id="graph-tooltip">
|
|
391
|
+
<div class="graph-tooltip-name"></div>
|
|
392
|
+
<div class="graph-tooltip-type"></div>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
<div id="graph-detail" class="graph-detail">
|
|
396
|
+
<div class="graph-detail-empty">${t('clickNodeToView') || 'Click a node to view details'}</div>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
`;
|
|
400
|
+
|
|
401
|
+
renderGraph(graph);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ============================================================
|
|
405
|
+
// Canvas-based Force-Directed Graph (Enhanced)
|
|
406
|
+
// ============================================================
|
|
407
|
+
|
|
408
|
+
function renderGraph(graph) {
|
|
409
|
+
const canvas = document.getElementById('graph-canvas');
|
|
410
|
+
const ctx = canvas.getContext('2d');
|
|
411
|
+
const container = document.getElementById('graph-container');
|
|
412
|
+
|
|
413
|
+
const rect = container.getBoundingClientRect();
|
|
414
|
+
const dpr = window.devicePixelRatio || 1;
|
|
415
|
+
canvas.width = rect.width * dpr;
|
|
416
|
+
canvas.height = rect.height * dpr;
|
|
417
|
+
canvas.style.width = rect.width + 'px';
|
|
418
|
+
canvas.style.height = rect.height + 'px';
|
|
419
|
+
ctx.scale(dpr, dpr);
|
|
420
|
+
|
|
421
|
+
const W = rect.width;
|
|
422
|
+
const H = rect.height;
|
|
423
|
+
|
|
424
|
+
const typeColors = {};
|
|
425
|
+
const palette = ['#00d4ff', '#a855f7', '#ec4899', '#10b981', '#f59e0b', '#3b82f6', '#ef4444', '#f97316'];
|
|
426
|
+
let colorIdx = 0;
|
|
427
|
+
function getTypeColor(type) {
|
|
428
|
+
if (!typeColors[type]) { typeColors[type] = palette[colorIdx % palette.length]; colorIdx++; }
|
|
429
|
+
return typeColors[type];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Bigger nodes, wider spread
|
|
433
|
+
const nodes = graph.entities.map((e) => ({
|
|
434
|
+
id: e.name, type: e.entityType, observations: e.observations,
|
|
435
|
+
x: W / 2 + (Math.random() - 0.5) * W * 0.7,
|
|
436
|
+
y: H / 2 + (Math.random() - 0.5) * H * 0.7,
|
|
437
|
+
vx: 0, vy: 0,
|
|
438
|
+
radius: Math.min(10 + e.observations.length * 3, 32),
|
|
439
|
+
color: getTypeColor(e.entityType),
|
|
440
|
+
}));
|
|
441
|
+
const nodeMap = {};
|
|
442
|
+
nodes.forEach(n => nodeMap[n.id] = n);
|
|
443
|
+
|
|
444
|
+
const edges = graph.relations
|
|
445
|
+
.filter(r => nodeMap[r.from] && nodeMap[r.to])
|
|
446
|
+
.map(r => ({ source: nodeMap[r.from], target: nodeMap[r.to], type: r.relationType }));
|
|
447
|
+
|
|
448
|
+
// Stronger repulsion to fill the canvas
|
|
449
|
+
const REPULSION = 8000;
|
|
450
|
+
const ATTRACTION = 0.003;
|
|
451
|
+
const DAMPING = 0.82;
|
|
452
|
+
const CENTER_GRAVITY = 0.008;
|
|
453
|
+
|
|
454
|
+
let animating = true;
|
|
455
|
+
let hoveredNode = null;
|
|
456
|
+
let selectedNode = null;
|
|
457
|
+
let dragNode = null;
|
|
458
|
+
let pulsePhase = 0;
|
|
459
|
+
|
|
460
|
+
function simulate() {
|
|
461
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
462
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
463
|
+
const a = nodes[i], b = nodes[j];
|
|
464
|
+
let dx = b.x - a.x, dy = b.y - a.y;
|
|
465
|
+
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
466
|
+
let force = REPULSION / (dist * dist);
|
|
467
|
+
let fx = (dx / dist) * force, fy = (dy / dist) * force;
|
|
468
|
+
a.vx -= fx; a.vy -= fy;
|
|
469
|
+
b.vx += fx; b.vy += fy;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
for (const edge of edges) {
|
|
473
|
+
let dx = edge.target.x - edge.source.x, dy = edge.target.y - edge.source.y;
|
|
474
|
+
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
475
|
+
let force = (dist - 150) * ATTRACTION;
|
|
476
|
+
let fx = (dx / dist) * force, fy = (dy / dist) * force;
|
|
477
|
+
edge.source.vx += fx; edge.source.vy += fy;
|
|
478
|
+
edge.target.vx -= fx; edge.target.vy -= fy;
|
|
479
|
+
}
|
|
480
|
+
for (const node of nodes) {
|
|
481
|
+
node.vx += (W / 2 - node.x) * CENTER_GRAVITY;
|
|
482
|
+
node.vy += (H / 2 - node.y) * CENTER_GRAVITY;
|
|
483
|
+
}
|
|
484
|
+
let totalMovement = 0;
|
|
485
|
+
for (const node of nodes) {
|
|
486
|
+
if (node === dragNode) continue;
|
|
487
|
+
node.vx *= DAMPING; node.vy *= DAMPING;
|
|
488
|
+
node.x += node.vx; node.y += node.vy;
|
|
489
|
+
node.x = Math.max(node.radius + 20, Math.min(W - node.radius - 20, node.x));
|
|
490
|
+
node.y = Math.max(node.radius + 20, Math.min(H - node.radius - 20, node.y));
|
|
491
|
+
totalMovement += Math.abs(node.vx) + Math.abs(node.vy);
|
|
492
|
+
}
|
|
493
|
+
return totalMovement;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function getGraphColors() {
|
|
497
|
+
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
|
498
|
+
return {
|
|
499
|
+
edgeNormal: isLight ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.06)',
|
|
500
|
+
edgeHighlight: isLight ? 'rgba(0,0,0,0.25)' : 'rgba(255,255,255,0.25)',
|
|
501
|
+
edgeLabel: isLight ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.45)',
|
|
502
|
+
labelNormal: isLight ? 'rgba(0,0,0,0.55)' : 'rgba(255,255,255,0.6)',
|
|
503
|
+
labelHover: isLight ? '#1a1a2e' : '#ffffff',
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function draw() {
|
|
508
|
+
ctx.clearRect(0, 0, W, H);
|
|
509
|
+
const colors = getGraphColors();
|
|
510
|
+
pulsePhase += 0.02;
|
|
511
|
+
|
|
512
|
+
// Edges with curve + arrow
|
|
513
|
+
for (const edge of edges) {
|
|
514
|
+
const isActive = (hoveredNode && (edge.source === hoveredNode || edge.target === hoveredNode))
|
|
515
|
+
|| (selectedNode && (edge.source === selectedNode || edge.target === selectedNode));
|
|
516
|
+
const mx = (edge.source.x + edge.target.x) / 2;
|
|
517
|
+
const my = (edge.source.y + edge.target.y) / 2;
|
|
518
|
+
const dx = edge.target.x - edge.source.x;
|
|
519
|
+
const dy = edge.target.y - edge.source.y;
|
|
520
|
+
const ox = -dy * 0.08, oy = dx * 0.08;
|
|
521
|
+
|
|
522
|
+
ctx.beginPath();
|
|
523
|
+
ctx.moveTo(edge.source.x, edge.source.y);
|
|
524
|
+
ctx.quadraticCurveTo(mx + ox, my + oy, edge.target.x, edge.target.y);
|
|
525
|
+
ctx.strokeStyle = isActive ? colors.edgeHighlight : colors.edgeNormal;
|
|
526
|
+
ctx.lineWidth = isActive ? 2 : 1;
|
|
527
|
+
ctx.stroke();
|
|
528
|
+
|
|
529
|
+
// Arrow
|
|
530
|
+
const as = isActive ? 8 : 5;
|
|
531
|
+
const angle = Math.atan2(edge.target.y - (my + oy), edge.target.x - (mx + ox));
|
|
532
|
+
const tx = edge.target.x - Math.cos(angle) * edge.target.radius;
|
|
533
|
+
const ty = edge.target.y - Math.sin(angle) * edge.target.radius;
|
|
534
|
+
ctx.beginPath();
|
|
535
|
+
ctx.moveTo(tx, ty);
|
|
536
|
+
ctx.lineTo(tx - as * Math.cos(angle - 0.3), ty - as * Math.sin(angle - 0.3));
|
|
537
|
+
ctx.lineTo(tx - as * Math.cos(angle + 0.3), ty - as * Math.sin(angle + 0.3));
|
|
538
|
+
ctx.closePath();
|
|
539
|
+
ctx.fillStyle = isActive ? colors.edgeHighlight : colors.edgeNormal;
|
|
540
|
+
ctx.fill();
|
|
541
|
+
|
|
542
|
+
if (isActive) {
|
|
543
|
+
ctx.font = '10px Inter, sans-serif';
|
|
544
|
+
ctx.fillStyle = colors.edgeLabel;
|
|
545
|
+
ctx.textAlign = 'center';
|
|
546
|
+
ctx.fillText(edge.type, mx + ox, my + oy - 6);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Nodes
|
|
551
|
+
for (const node of nodes) {
|
|
552
|
+
const active = node === hoveredNode || node === selectedNode;
|
|
553
|
+
|
|
554
|
+
// Glow + dashed ring
|
|
555
|
+
if (active) {
|
|
556
|
+
const pr = node.radius + 12 + Math.sin(pulsePhase * 3) * 3;
|
|
557
|
+
ctx.beginPath();
|
|
558
|
+
ctx.arc(node.x, node.y, pr, 0, Math.PI * 2);
|
|
559
|
+
const g = ctx.createRadialGradient(node.x, node.y, node.radius, node.x, node.y, pr);
|
|
560
|
+
g.addColorStop(0, node.color + '30');
|
|
561
|
+
g.addColorStop(1, node.color + '00');
|
|
562
|
+
ctx.fillStyle = g;
|
|
563
|
+
ctx.fill();
|
|
564
|
+
ctx.beginPath();
|
|
565
|
+
ctx.arc(node.x, node.y, node.radius + 6, 0, Math.PI * 2);
|
|
566
|
+
ctx.setLineDash([3, 3]);
|
|
567
|
+
ctx.strokeStyle = node.color + '50';
|
|
568
|
+
ctx.lineWidth = 1;
|
|
569
|
+
ctx.stroke();
|
|
570
|
+
ctx.setLineDash([]);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Main sphere with gradient
|
|
574
|
+
ctx.beginPath();
|
|
575
|
+
ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
|
|
576
|
+
const ng = ctx.createRadialGradient(node.x - node.radius * 0.3, node.y - node.radius * 0.3, 0, node.x, node.y, node.radius);
|
|
577
|
+
ng.addColorStop(0, node.color);
|
|
578
|
+
ng.addColorStop(1, node.color + (active ? 'cc' : '99'));
|
|
579
|
+
ctx.fillStyle = ng;
|
|
580
|
+
ctx.fill();
|
|
581
|
+
|
|
582
|
+
// Inner highlight
|
|
583
|
+
ctx.beginPath();
|
|
584
|
+
ctx.arc(node.x - node.radius * 0.2, node.y - node.radius * 0.2, node.radius * 0.3, 0, Math.PI * 2);
|
|
585
|
+
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
|
586
|
+
ctx.fill();
|
|
587
|
+
|
|
588
|
+
// Label
|
|
589
|
+
ctx.font = `${active ? '600' : '400'} ${active ? 13 : 11}px Inter, sans-serif`;
|
|
590
|
+
ctx.fillStyle = active ? colors.labelHover : colors.labelNormal;
|
|
591
|
+
ctx.textAlign = 'center';
|
|
592
|
+
ctx.fillText(node.id, node.x, node.y + node.radius + 18);
|
|
593
|
+
|
|
594
|
+
// Obs count badge
|
|
595
|
+
if (node.observations.length > 0) {
|
|
596
|
+
const bx = node.x + node.radius * 0.6, by = node.y - node.radius * 0.6;
|
|
597
|
+
ctx.beginPath();
|
|
598
|
+
ctx.arc(bx, by, 8, 0, Math.PI * 2);
|
|
599
|
+
ctx.fillStyle = node.color;
|
|
600
|
+
ctx.fill();
|
|
601
|
+
ctx.font = 'bold 8px Inter, sans-serif';
|
|
602
|
+
ctx.fillStyle = '#fff';
|
|
603
|
+
ctx.textAlign = 'center';
|
|
604
|
+
ctx.textBaseline = 'middle';
|
|
605
|
+
ctx.fillText(String(node.observations.length), bx, by);
|
|
606
|
+
ctx.textBaseline = 'alphabetic';
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (selectedNode && !animating) requestAnimationFrame(draw);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function showDetail(node) {
|
|
614
|
+
const panel = document.getElementById('graph-detail');
|
|
615
|
+
if (!node) {
|
|
616
|
+
panel.innerHTML = `<div class="graph-detail-empty">${t('clickNodeToView') || 'Click a node to view details'}</div>`;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const related = edges.filter(e => e.source === node || e.target === node);
|
|
620
|
+
const obsHtml = node.observations.length > 0
|
|
621
|
+
? node.observations.map(o => `<div class="graph-obs-item">${escapeHtml(o)}</div>`).join('')
|
|
622
|
+
: `<div class="graph-detail-muted">${t('noObservations') || 'No observations'}</div>`;
|
|
623
|
+
const relHtml = related.length > 0
|
|
624
|
+
? related.map(e => {
|
|
625
|
+
const dir = e.source === node;
|
|
626
|
+
const other = dir ? e.target : e.source;
|
|
627
|
+
return `<div class="graph-rel-item"><span class="graph-rel-arrow">${dir ? '→' : '←'}</span> <span class="graph-rel-type">${escapeHtml(e.type)}</span> <strong>${escapeHtml(other.id)}</strong></div>`;
|
|
628
|
+
}).join('')
|
|
629
|
+
: `<div class="graph-detail-muted">${t('noRelations') || 'No relations'}</div>`;
|
|
630
|
+
|
|
631
|
+
panel.innerHTML = `
|
|
632
|
+
<div class="graph-detail-header">
|
|
633
|
+
<div class="graph-detail-dot" style="background:${node.color}"></div>
|
|
634
|
+
<div>
|
|
635
|
+
<div class="graph-detail-name">${escapeHtml(node.id)}</div>
|
|
636
|
+
<div class="graph-detail-type">${escapeHtml(node.type)}</div>
|
|
637
|
+
</div>
|
|
638
|
+
</div>
|
|
639
|
+
<div class="graph-detail-section">
|
|
640
|
+
<h3>${t('observations')} <span class="graph-detail-count">${node.observations.length}</span></h3>
|
|
641
|
+
${obsHtml}
|
|
642
|
+
</div>
|
|
643
|
+
<div class="graph-detail-section">
|
|
644
|
+
<h3>${t('relations')} <span class="graph-detail-count">${related.length}</span></h3>
|
|
645
|
+
${relHtml}
|
|
646
|
+
</div>
|
|
647
|
+
`;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function tick() {
|
|
651
|
+
const movement = simulate();
|
|
652
|
+
draw();
|
|
653
|
+
if (animating && movement > 0.1) requestAnimationFrame(tick);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
canvas.addEventListener('mousemove', (e) => {
|
|
657
|
+
const r = canvas.getBoundingClientRect();
|
|
658
|
+
const mx = e.clientX - r.left, my = e.clientY - r.top;
|
|
659
|
+
if (dragNode) { dragNode.x = mx; dragNode.y = my; dragNode.vx = 0; dragNode.vy = 0; draw(); return; }
|
|
660
|
+
let found = null;
|
|
661
|
+
for (const node of nodes) {
|
|
662
|
+
const dx = mx - node.x, dy = my - node.y;
|
|
663
|
+
if (dx * dx + dy * dy < (node.radius + 6) * (node.radius + 6)) { found = node; break; }
|
|
664
|
+
}
|
|
665
|
+
if (found !== hoveredNode) {
|
|
666
|
+
hoveredNode = found;
|
|
667
|
+
canvas.style.cursor = found ? 'pointer' : 'default';
|
|
668
|
+
if (found) {
|
|
669
|
+
const tt = document.getElementById('graph-tooltip');
|
|
670
|
+
tt.querySelector('.graph-tooltip-name').textContent = found.id;
|
|
671
|
+
tt.querySelector('.graph-tooltip-type').textContent = `${found.type} · ${found.observations.length} ${t('observation_s')}`;
|
|
672
|
+
tt.style.left = (mx + 16) + 'px';
|
|
673
|
+
tt.style.top = (my - 20) + 'px';
|
|
674
|
+
tt.classList.add('visible');
|
|
675
|
+
} else {
|
|
676
|
+
document.getElementById('graph-tooltip').classList.remove('visible');
|
|
677
|
+
}
|
|
678
|
+
draw();
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
canvas.addEventListener('mousedown', () => { if (hoveredNode) { dragNode = hoveredNode; canvas.style.cursor = 'grabbing'; } });
|
|
682
|
+
canvas.addEventListener('mouseup', () => { if (dragNode) { dragNode = null; canvas.style.cursor = hoveredNode ? 'pointer' : 'default'; animating = true; tick(); } });
|
|
683
|
+
canvas.addEventListener('click', () => { if (hoveredNode) { selectedNode = hoveredNode; showDetail(selectedNode); draw(); } });
|
|
684
|
+
canvas.addEventListener('mouseleave', () => { hoveredNode = null; dragNode = null; document.getElementById('graph-tooltip').classList.remove('visible'); draw(); });
|
|
685
|
+
|
|
686
|
+
tick();
|
|
687
|
+
setTimeout(() => { animating = false; }, 8000);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ============================================================
|
|
691
|
+
// Observations Page
|
|
692
|
+
// ============================================================
|
|
693
|
+
|
|
694
|
+
let allObservations = [];
|
|
695
|
+
let obsFilter = '';
|
|
696
|
+
let obsTypeFilter = '';
|
|
697
|
+
|
|
698
|
+
async function loadObservations() {
|
|
699
|
+
const container = document.getElementById('page-observations');
|
|
700
|
+
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
|
701
|
+
|
|
702
|
+
allObservations = await api('observations') || [];
|
|
703
|
+
|
|
704
|
+
if (allObservations.length === 0) {
|
|
705
|
+
container.innerHTML = emptyState('🔍', t('noObsTitle'), t('noObsDesc'));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
allObservations.sort((a, b) => (b.id || 0) - (a.id || 0));
|
|
710
|
+
|
|
711
|
+
const types = [...new Set(allObservations.map(o => o.type).filter(Boolean))];
|
|
712
|
+
|
|
713
|
+
container.innerHTML = `
|
|
714
|
+
<div class="page-header">
|
|
715
|
+
<h1 class="page-title">${t('observations')}</h1>
|
|
716
|
+
<p class="page-subtitle">${allObservations.length} ${t('observationsStored')}</p>
|
|
717
|
+
</div>
|
|
718
|
+
|
|
719
|
+
<div class="search-bar">
|
|
720
|
+
<input class="search-input" id="obs-search" type="text" placeholder="${t('searchObservations')}" />
|
|
721
|
+
<button class="filter-btn active" data-type="" id="filter-all">${t('all')}</button>
|
|
722
|
+
${types.map(tp => `<button class="filter-btn" data-type="${tp}">${tp}</button>`).join('')}
|
|
723
|
+
</div>
|
|
724
|
+
|
|
725
|
+
<div class="obs-grid" id="obs-list"></div>
|
|
726
|
+
`;
|
|
727
|
+
|
|
728
|
+
document.getElementById('obs-search').addEventListener('input', (e) => {
|
|
729
|
+
obsFilter = e.target.value.toLowerCase();
|
|
730
|
+
renderObsList();
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
container.querySelectorAll('.filter-btn').forEach(btn => {
|
|
734
|
+
btn.addEventListener('click', () => {
|
|
735
|
+
container.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
|
736
|
+
btn.classList.add('active');
|
|
737
|
+
obsTypeFilter = btn.dataset.type;
|
|
738
|
+
renderObsList();
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
renderObsList();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function renderObsList() {
|
|
746
|
+
const list = document.getElementById('obs-list');
|
|
747
|
+
if (!list) return;
|
|
748
|
+
|
|
749
|
+
const typeIcons = {
|
|
750
|
+
'session-request': '🎯', gotcha: '🔴', 'problem-solution': '🟡',
|
|
751
|
+
'how-it-works': '🔵', 'what-changed': '🟢', discovery: '🟣',
|
|
752
|
+
'why-it-exists': '🟠', decision: '🟤', 'trade-off': '⚖️',
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
let filtered = allObservations;
|
|
756
|
+
|
|
757
|
+
if (obsTypeFilter) {
|
|
758
|
+
filtered = filtered.filter(o => o.type === obsTypeFilter);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (obsFilter) {
|
|
762
|
+
filtered = filtered.filter(o =>
|
|
763
|
+
(o.title || '').toLowerCase().includes(obsFilter) ||
|
|
764
|
+
(o.narrative || '').toLowerCase().includes(obsFilter) ||
|
|
765
|
+
(o.entityName || '').toLowerCase().includes(obsFilter) ||
|
|
766
|
+
(o.facts || []).some(f => f.toLowerCase().includes(obsFilter))
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (filtered.length === 0) {
|
|
771
|
+
list.innerHTML = `<div style="padding: 40px; text-align: center; color: var(--text-muted);">${t('noMatchingObs')}</div>`;
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
list.innerHTML = filtered.map(obs => `
|
|
776
|
+
<div class="obs-card">
|
|
777
|
+
<div class="obs-card-header">
|
|
778
|
+
<span class="obs-card-id">#${obs.id}</span>
|
|
779
|
+
<span class="type-badge" data-type="${obs.type || 'unknown'}">
|
|
780
|
+
${typeIcons[obs.type] || '❓'} ${obs.type || 'unknown'}
|
|
781
|
+
</span>
|
|
782
|
+
<span class="obs-card-title">${escapeHtml(obs.title || t('untitled'))}</span>
|
|
783
|
+
</div>
|
|
784
|
+
<div class="obs-card-meta">
|
|
785
|
+
<span>📁 ${escapeHtml(obs.entityName || 'unknown')}</span>
|
|
786
|
+
${obs.createdAt ? `<span>🕐 ${formatTime(obs.createdAt)}</span>` : ''}
|
|
787
|
+
${obs.accessCount ? `<span>👁 ${obs.accessCount}</span>` : ''}
|
|
788
|
+
</div>
|
|
789
|
+
${obs.narrative ? `<div class="obs-card-narrative">${escapeHtml(obs.narrative)}</div>` : ''}
|
|
790
|
+
${obs.facts && obs.facts.length > 0 ? `
|
|
791
|
+
<div class="obs-card-facts">
|
|
792
|
+
${obs.facts.map(f => `<span class="fact-tag">${escapeHtml(f)}</span>`).join('')}
|
|
793
|
+
</div>
|
|
794
|
+
` : ''}
|
|
795
|
+
</div>
|
|
796
|
+
`).join('');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// ============================================================
|
|
800
|
+
// Retention Page
|
|
801
|
+
// ============================================================
|
|
802
|
+
|
|
803
|
+
async function loadRetention() {
|
|
804
|
+
const container = document.getElementById('page-retention');
|
|
805
|
+
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
|
806
|
+
|
|
807
|
+
const data = await api('retention');
|
|
808
|
+
if (!data || data.items.length === 0) {
|
|
809
|
+
container.innerHTML = emptyState('📉', t('noRetentionData'), t('noRetentionDesc'));
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const { summary, items } = data;
|
|
814
|
+
|
|
815
|
+
container.innerHTML = `
|
|
816
|
+
<div class="page-header">
|
|
817
|
+
<h1 class="page-title">${t('memoryRetention')}</h1>
|
|
818
|
+
<p class="page-subtitle">${t('retentionSubtitle')}</p>
|
|
819
|
+
</div>
|
|
820
|
+
|
|
821
|
+
<div class="retention-summary">
|
|
822
|
+
<div class="stat-card" data-accent="green">
|
|
823
|
+
<div class="stat-label">${t('active')}</div>
|
|
824
|
+
<div class="stat-value">${summary.active}</div>
|
|
825
|
+
</div>
|
|
826
|
+
<div class="stat-card" data-accent="amber">
|
|
827
|
+
<div class="stat-label">${t('stale')}</div>
|
|
828
|
+
<div class="stat-value">${summary.stale}</div>
|
|
829
|
+
</div>
|
|
830
|
+
<div class="stat-card" data-accent="cyan">
|
|
831
|
+
<div class="stat-label">${t('archiveCandidates')}</div>
|
|
832
|
+
<div class="stat-value">${summary.archive}</div>
|
|
833
|
+
</div>
|
|
834
|
+
<div class="stat-card" data-accent="purple">
|
|
835
|
+
<div class="stat-label">${t('immune')}</div>
|
|
836
|
+
<div class="stat-value">${summary.immune}</div>
|
|
837
|
+
</div>
|
|
838
|
+
</div>
|
|
839
|
+
|
|
840
|
+
<div class="panel">
|
|
841
|
+
<div class="panel-header">
|
|
842
|
+
<span class="panel-title">${t('allObsByScore')}</span>
|
|
843
|
+
</div>
|
|
844
|
+
<div class="panel-body" style="padding: 0;">
|
|
845
|
+
<table class="retention-table">
|
|
846
|
+
<thead>
|
|
847
|
+
<tr>
|
|
848
|
+
<th>${t('id')}</th>
|
|
849
|
+
<th>${t('title')}</th>
|
|
850
|
+
<th>${t('type')}</th>
|
|
851
|
+
<th>${t('entity')}</th>
|
|
852
|
+
<th>${t('score')}</th>
|
|
853
|
+
<th>${t('ageH')}</th>
|
|
854
|
+
<th>${t('access')}</th>
|
|
855
|
+
<th>${t('status')}</th>
|
|
856
|
+
</tr>
|
|
857
|
+
</thead>
|
|
858
|
+
<tbody>
|
|
859
|
+
${items.map(item => {
|
|
860
|
+
const scorePercent = Math.min(item.score / 10 * 100, 100);
|
|
861
|
+
const scoreColor = item.score >= 5 ? 'var(--accent-green)' : item.score >= 3 ? 'var(--accent-amber)' : item.score >= 1 ? 'var(--accent-red)' : 'var(--text-muted)';
|
|
862
|
+
return `
|
|
863
|
+
<tr>
|
|
864
|
+
<td style="font-family: var(--font-mono); color: var(--text-muted);">#${item.id}</td>
|
|
865
|
+
<td style="color: var(--text-primary); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(item.title || t('untitled'))}</td>
|
|
866
|
+
<td><span class="type-badge" data-type="${item.type}">${item.type}</span></td>
|
|
867
|
+
<td style="font-family: var(--font-mono); color: var(--text-muted); font-size: 12px;">${escapeHtml(item.entityName || '')}</td>
|
|
868
|
+
<td>
|
|
869
|
+
<div class="score-bar"><div class="score-bar-fill" style="width: ${scorePercent}%; background: ${scoreColor};"></div></div>
|
|
870
|
+
<span style="font-family: var(--font-mono); font-size: 12px; color: ${scoreColor};">${item.score}</span>
|
|
871
|
+
</td>
|
|
872
|
+
<td style="font-family: var(--font-mono); color: var(--text-muted); font-size: 12px;">${item.ageHours}h</td>
|
|
873
|
+
<td style="font-family: var(--font-mono); color: var(--text-muted); font-size: 12px;">${item.accessCount}</td>
|
|
874
|
+
<td>${item.isImmune ? `<span class="immune-badge">🛡️ ${t('immune')}</span>` : ''}</td>
|
|
875
|
+
</tr>
|
|
876
|
+
`;
|
|
877
|
+
}).join('')}
|
|
878
|
+
</tbody>
|
|
879
|
+
</table>
|
|
880
|
+
</div>
|
|
881
|
+
</div>
|
|
882
|
+
`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ============================================================
|
|
886
|
+
// Utilities
|
|
887
|
+
// ============================================================
|
|
888
|
+
|
|
889
|
+
function escapeHtml(text) {
|
|
890
|
+
const div = document.createElement('div');
|
|
891
|
+
div.textContent = text;
|
|
892
|
+
return div.innerHTML;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function formatTime(isoString) {
|
|
896
|
+
try {
|
|
897
|
+
const d = new Date(isoString);
|
|
898
|
+
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
899
|
+
} catch {
|
|
900
|
+
return isoString;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function emptyState(icon, title, desc) {
|
|
905
|
+
return `
|
|
906
|
+
<div class="empty-state">
|
|
907
|
+
<div class="empty-state-icon">${icon}</div>
|
|
908
|
+
<div class="empty-state-title">${title}</div>
|
|
909
|
+
<div class="empty-state-desc">${desc}</div>
|
|
910
|
+
</div>
|
|
911
|
+
`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ============================================================
|
|
915
|
+
// Init
|
|
916
|
+
// ============================================================
|
|
917
|
+
|
|
918
|
+
// Apply initial language to nav tooltips
|
|
919
|
+
setLang(currentLang);
|
|
920
|
+
|
|
921
|
+
loadPage('dashboard');
|