@jagilber-org/index-server 1.22.0 → 1.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -2
- package/CODE_OF_CONDUCT.md +2 -0
- package/CONTRIBUTING.md +32 -2
- package/README.md +83 -20
- package/SECURITY.md +17 -5
- package/dist/config/dashboardConfig.d.ts +3 -0
- package/dist/config/dashboardConfig.js +3 -0
- package/dist/config/defaultValues.d.ts +1 -1
- package/dist/config/defaultValues.js +1 -1
- package/dist/config/featureConfig.d.ts +2 -0
- package/dist/config/featureConfig.js +6 -1
- package/dist/config/runtimeConfig.d.ts +1 -1
- package/dist/config/runtimeConfig.js +8 -9
- package/dist/dashboard/client/admin.html +173 -54
- package/dist/dashboard/client/css/admin.css +151 -0
- package/dist/dashboard/client/js/admin.auth.js +25 -11
- package/dist/dashboard/client/js/admin.config.js +1 -1
- package/dist/dashboard/client/js/admin.feedback.js +328 -0
- package/dist/dashboard/client/js/admin.graph.js +120 -18
- package/dist/dashboard/client/js/admin.instructions.js +27 -13
- package/dist/dashboard/client/js/admin.logs.js +1 -5
- package/dist/dashboard/client/js/admin.maintenance.js +53 -8
- package/dist/dashboard/client/js/admin.messaging.js +1 -4
- package/dist/dashboard/client/js/admin.overview.js +5 -1
- package/dist/dashboard/client/js/admin.sessions.js +1 -1
- package/dist/dashboard/client/js/admin.utils.js +43 -1
- package/dist/dashboard/client/js/mermaid.min.js +813 -537
- package/dist/dashboard/export/DataExporter.js +2 -1
- package/dist/dashboard/server/AdminPanel.d.ts +3 -0
- package/dist/dashboard/server/AdminPanel.js +132 -35
- package/dist/dashboard/server/ApiRoutes.js +40 -9
- package/dist/dashboard/server/DashboardServer.js +1 -1
- package/dist/dashboard/server/FileMetricsStorage.d.ts +19 -0
- package/dist/dashboard/server/FileMetricsStorage.js +52 -5
- package/dist/dashboard/server/HttpTransport.js +6 -0
- package/dist/dashboard/server/InstanceManager.js +7 -2
- package/dist/dashboard/server/KnowledgeStore.js +7 -2
- package/dist/dashboard/server/MetricsCollector.d.ts +16 -0
- package/dist/dashboard/server/MetricsCollector.js +113 -17
- package/dist/dashboard/server/legacyDashboardHtml.js +7 -2
- package/dist/dashboard/server/middleware/ensureLoadedMiddleware.d.ts +1 -1
- package/dist/dashboard/server/middleware/ensureLoadedMiddleware.js +8 -3
- package/dist/dashboard/server/routes/admin.feedback.routes.d.ts +15 -0
- package/dist/dashboard/server/routes/admin.feedback.routes.js +188 -0
- package/dist/dashboard/server/routes/admin.routes.js +35 -27
- package/dist/dashboard/server/routes/alerts.routes.js +4 -3
- package/dist/dashboard/server/routes/api.feedback.routes.js +2 -1
- package/dist/dashboard/server/routes/api.usage.routes.js +8 -7
- package/dist/dashboard/server/routes/embeddings.routes.d.ts +2 -1
- package/dist/dashboard/server/routes/embeddings.routes.js +18 -9
- package/dist/dashboard/server/routes/graph.routes.js +10 -13
- package/dist/dashboard/server/routes/index.d.ts +1 -0
- package/dist/dashboard/server/routes/index.js +74 -39
- package/dist/dashboard/server/routes/instances.routes.js +2 -1
- package/dist/dashboard/server/routes/instructions.routes.js +46 -27
- package/dist/dashboard/server/routes/knowledge.routes.js +4 -3
- package/dist/dashboard/server/routes/logs.routes.js +5 -4
- package/dist/dashboard/server/routes/messaging.routes.js +15 -14
- package/dist/dashboard/server/routes/metrics.routes.js +14 -13
- package/dist/dashboard/server/routes/scripts.routes.js +6 -3
- package/dist/dashboard/server/routes/status.routes.js +25 -6
- package/dist/dashboard/server/routes/synthetic.routes.js +3 -2
- package/dist/dashboard/server/routes/usage.routes.js +2 -1
- package/dist/dashboard/server/utils/escapeHtml.d.ts +1 -0
- package/dist/dashboard/server/utils/escapeHtml.js +11 -0
- package/dist/dashboard/server/utils/pathContainment.d.ts +1 -0
- package/dist/dashboard/server/utils/pathContainment.js +15 -0
- package/dist/dashboard/server/wsInit.js +2 -2
- package/dist/lib/mcpStdioLogging.d.ts +165 -0
- package/dist/lib/mcpStdioLogging.js +287 -0
- package/dist/schemas/index.d.ts +37 -2
- package/dist/schemas/index.js +27 -3
- package/dist/server/backgroundServicesStartup.d.ts +7 -1
- package/dist/server/backgroundServicesStartup.js +25 -8
- package/dist/server/certInit.d.ts +97 -0
- package/dist/server/certInit.js +359 -0
- package/dist/server/certInit.types.d.ts +92 -0
- package/dist/server/certInit.types.js +34 -0
- package/dist/server/handshake/fallbackFrames.d.ts +31 -0
- package/dist/server/handshake/fallbackFrames.js +38 -0
- package/dist/server/handshake/initializeDetector.d.ts +31 -0
- package/dist/server/handshake/initializeDetector.js +88 -0
- package/dist/server/handshake/protocol.d.ts +15 -0
- package/dist/server/handshake/protocol.js +37 -0
- package/dist/server/handshake/readyEmitter.d.ts +6 -0
- package/dist/server/handshake/readyEmitter.js +88 -0
- package/dist/server/handshake/safetyFallbacks.d.ts +1 -0
- package/dist/server/handshake/safetyFallbacks.js +134 -0
- package/dist/server/handshake/stdinSniffer.d.ts +1 -0
- package/dist/server/handshake/stdinSniffer.js +260 -0
- package/dist/server/handshake/tracing.d.ts +16 -0
- package/dist/server/handshake/tracing.js +95 -0
- package/dist/server/handshakeManager.d.ts +23 -23
- package/dist/server/handshakeManager.js +36 -466
- package/dist/server/index-server.d.ts +23 -0
- package/dist/server/index-server.js +194 -9
- package/dist/server/mcpReadOnlySurfaces.d.ts +44 -0
- package/dist/server/mcpReadOnlySurfaces.js +297 -0
- package/dist/server/sdkServer.js +69 -7
- package/dist/server/transport.d.ts +5 -6
- package/dist/server/transport.js +46 -64
- package/dist/server/transportFactory.d.ts +3 -9
- package/dist/server/transportFactory.js +18 -380
- package/dist/services/atomicFs.d.ts +3 -0
- package/dist/services/atomicFs.js +171 -13
- package/dist/services/auditLog.d.ts +17 -2
- package/dist/services/auditLog.js +75 -14
- package/dist/services/bootstrapGating.js +1 -1
- package/dist/services/categoryRules.d.ts +10 -0
- package/dist/services/categoryRules.js +17 -0
- package/dist/services/classificationService.js +7 -5
- package/dist/services/embeddingService.d.ts +27 -11
- package/dist/services/embeddingService.js +51 -14
- package/dist/services/feedbackStorage.d.ts +39 -0
- package/dist/services/feedbackStorage.js +88 -0
- package/dist/services/handlers/instructions.add.js +429 -317
- package/dist/services/handlers/instructions.groom.js +128 -31
- package/dist/services/handlers/instructions.import.js +56 -23
- package/dist/services/handlers/instructions.patch.js +43 -32
- package/dist/services/handlers/instructions.query.js +20 -29
- package/dist/services/handlers/instructions.shared.d.ts +54 -0
- package/dist/services/handlers/instructions.shared.js +126 -1
- package/dist/services/handlers.activation.js +83 -81
- package/dist/services/handlers.dashboardConfig.d.ts +2 -2
- package/dist/services/handlers.dashboardConfig.js +1 -2
- package/dist/services/handlers.diagnostics.js +75 -54
- package/dist/services/handlers.feedback.d.ts +4 -11
- package/dist/services/handlers.feedback.js +11 -333
- package/dist/services/handlers.gates.js +69 -37
- package/dist/services/handlers.graph.js +2 -2
- package/dist/services/handlers.help.js +2 -2
- package/dist/services/handlers.instructionSchema.js +4 -2
- package/dist/services/handlers.integrity.js +42 -22
- package/dist/services/handlers.messaging.js +1 -1
- package/dist/services/handlers.metrics.js +51 -6
- package/dist/services/handlers.prompt.js +10 -2
- package/dist/services/handlers.search.js +94 -44
- package/dist/services/handlers.trace.js +1 -1
- package/dist/services/handlers.usage.js +38 -7
- package/dist/services/indexContext.d.ts +21 -1
- package/dist/services/indexContext.js +267 -82
- package/dist/services/indexLoader.d.ts +1 -0
- package/dist/services/indexLoader.js +28 -8
- package/dist/services/instructionRecordValidation.d.ts +39 -0
- package/dist/services/instructionRecordValidation.js +388 -0
- package/dist/services/instructions.dispatcher.js +4 -4
- package/dist/services/loaderSchemaValidator.d.ts +15 -0
- package/dist/services/loaderSchemaValidator.js +69 -0
- package/dist/services/logger.js +11 -2
- package/dist/services/mcpLogBridge.d.ts +49 -0
- package/dist/services/mcpLogBridge.js +83 -0
- package/dist/services/ownershipService.js +18 -8
- package/dist/services/performanceBaseline.js +23 -22
- package/dist/services/promptReviewService.d.ts +3 -1
- package/dist/services/promptReviewService.js +41 -13
- package/dist/services/regexSafety.d.ts +6 -0
- package/dist/services/regexSafety.js +46 -0
- package/dist/services/seedBootstrap.js +4 -4
- package/dist/services/storage/factory.d.ts +14 -1
- package/dist/services/storage/factory.js +61 -1
- package/dist/services/storage/jsonEmbeddingStore.d.ts +15 -0
- package/dist/services/storage/jsonEmbeddingStore.js +83 -0
- package/dist/services/storage/jsonFileStore.d.ts +3 -1
- package/dist/services/storage/jsonFileStore.js +8 -6
- package/dist/services/storage/migrationEngine.d.ts +13 -0
- package/dist/services/storage/migrationEngine.js +31 -0
- package/dist/services/storage/sqliteEmbeddingStore.d.ts +30 -0
- package/dist/services/storage/sqliteEmbeddingStore.js +222 -0
- package/dist/services/storage/sqliteStore.d.ts +3 -1
- package/dist/services/storage/sqliteStore.js +2 -2
- package/dist/services/storage/types.d.ts +48 -1
- package/dist/services/toolRegistry.js +77 -67
- package/dist/services/toolRegistry.zod.js +89 -86
- package/dist/services/tracing.js +5 -4
- package/dist/utils/envUtils.d.ts +4 -0
- package/dist/utils/envUtils.js +7 -0
- package/dist/utils/memoryMonitor.js +11 -10
- package/package.json +11 -4
- package/schemas/instruction.schema.json +38 -1
- package/scripts/copy-dashboard-assets.mjs +1 -1
- package/scripts/dist/README.md +1 -1
- package/scripts/setup-wizard.mjs +781 -0
- package/server.json +1 -0
- package/dist/externalClientLib.d.ts +0 -1
- package/dist/externalClientLib.js +0 -2
- package/dist/portableClientWrapper.d.ts +0 -1
- package/dist/portableClientWrapper.js +0 -2
- package/dist/services/indexingService.d.ts +0 -1
- package/dist/services/indexingService.js +0 -2
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
var _configRefreshTimer = null;
|
|
5
5
|
var _collapsedCategories = {};
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
var escapeHtml = window.adminUtils.escapeHtml;
|
|
8
8
|
|
|
9
9
|
function buildFlagRow(f, featureFlags) {
|
|
10
10
|
var isBool = f.type === 'boolean';
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin.feedback.js — Dashboard Feedback CRUD panel.
|
|
3
|
+
*
|
|
4
|
+
* Human-operator surface for browsing, creating, editing, and deleting
|
|
5
|
+
* persisted feedback entries, plus a client-side GitHub issue handoff.
|
|
6
|
+
*
|
|
7
|
+
* Design constraints (Morpheus architecture review / decisions.md G-1..G-8):
|
|
8
|
+
* - CRUD against /api/admin/feedback (operator-tier, NOT the MCP surface)
|
|
9
|
+
* - GitHub handoff is client-side ONLY — window.open with pre-filled URL
|
|
10
|
+
* - No server-side GitHub API calls, no token handling, no OAuth
|
|
11
|
+
* - GitHub target: https://github.com/jagilber-org/index-server
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
(function () {
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const GITHUB_ISSUES_URL = 'https://github.com/jagilber-org/index-server/issues/new';
|
|
20
|
+
const API_BASE = '/api/admin/feedback';
|
|
21
|
+
|
|
22
|
+
const STATUS_COLORS = {
|
|
23
|
+
'new': '#3b82f6',
|
|
24
|
+
'acknowledged': '#8b5cf6',
|
|
25
|
+
'in-progress': '#ff9830',
|
|
26
|
+
'resolved': '#73bf69',
|
|
27
|
+
'closed': '#8e959e',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const SEV_COLORS = {
|
|
31
|
+
'low': '#73bf69',
|
|
32
|
+
'medium': '#ff9830',
|
|
33
|
+
'high': '#f2495c',
|
|
34
|
+
'critical': '#dc2626',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ── Module state ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
let _entries = [];
|
|
40
|
+
let _selectedId = null;
|
|
41
|
+
let _filterText = '';
|
|
42
|
+
let _statusFilter = '';
|
|
43
|
+
let _editMode = null; // 'create' | 'edit' | null
|
|
44
|
+
|
|
45
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
window.initFeedback = async function () {
|
|
48
|
+
setupDelegation();
|
|
49
|
+
await loadEntries();
|
|
50
|
+
renderTable();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ── GitHub URL builder (tested by Tank B-2..B-5) ────────────────────────────
|
|
54
|
+
|
|
55
|
+
function buildGitHubIssueUrl(entry) {
|
|
56
|
+
const params = new URLSearchParams();
|
|
57
|
+
const titleParam = entry && entry.title
|
|
58
|
+
? `[Feedback] ${entry.title}`
|
|
59
|
+
: '[Feedback] New Issue';
|
|
60
|
+
params.set('title', titleParam);
|
|
61
|
+
|
|
62
|
+
const lines = [];
|
|
63
|
+
if (entry) {
|
|
64
|
+
if (entry.type) lines.push(`**Type:** ${entry.type}`);
|
|
65
|
+
if (entry.severity) lines.push(`**Severity:** ${entry.severity}`);
|
|
66
|
+
if (entry.status) lines.push(`**Status:** ${entry.status}`);
|
|
67
|
+
if (entry.description) lines.push(`\n${entry.description}`);
|
|
68
|
+
}
|
|
69
|
+
params.set('body', lines.join('\n'));
|
|
70
|
+
return `${GITHUB_ISSUES_URL}?${params.toString()}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Event delegation (CSP-safe) ─────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function setupDelegation() {
|
|
76
|
+
const section = document.getElementById('feedback-section');
|
|
77
|
+
if (!section || section._fbDelegated) return;
|
|
78
|
+
section._fbDelegated = true;
|
|
79
|
+
|
|
80
|
+
section.addEventListener('click', function (e) {
|
|
81
|
+
const el = e.target.closest('[data-fb-action]');
|
|
82
|
+
if (!el) return;
|
|
83
|
+
const action = el.dataset.fbAction;
|
|
84
|
+
|
|
85
|
+
if (action === 'refresh') { loadEntries().then(renderTable); return; }
|
|
86
|
+
if (action === 'create') { openCreate(); return; }
|
|
87
|
+
if (action === 'edit') { openEdit(el.dataset.id); return; }
|
|
88
|
+
if (action === 'delete') { confirmDelete(el.dataset.id); return; }
|
|
89
|
+
if (action === 'save') { saveEntry(); return; }
|
|
90
|
+
if (action === 'cancel') { closeDetail(); return; }
|
|
91
|
+
if (action === 'github') { openGitHubHandoff(); return; }
|
|
92
|
+
if (action === 'row-select') { openEdit(el.dataset.id); return; }
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const filterInput = document.getElementById('feedback-filter');
|
|
96
|
+
if (filterInput) {
|
|
97
|
+
filterInput.addEventListener('input', function () {
|
|
98
|
+
_filterText = this.value.toLowerCase();
|
|
99
|
+
renderTable();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const statusSel = document.getElementById('feedback-status-filter');
|
|
104
|
+
if (statusSel) {
|
|
105
|
+
statusSel.addEventListener('change', function () {
|
|
106
|
+
_statusFilter = this.value;
|
|
107
|
+
renderTable();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── API helpers ─────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
async function apiFetch(method, path, body) {
|
|
115
|
+
const opts = { method, headers: {} };
|
|
116
|
+
if (body !== undefined) {
|
|
117
|
+
opts.headers['Content-Type'] = 'application/json';
|
|
118
|
+
opts.body = JSON.stringify(body);
|
|
119
|
+
}
|
|
120
|
+
const res = await fetch(path, opts);
|
|
121
|
+
const text = await res.text();
|
|
122
|
+
let data;
|
|
123
|
+
try { data = JSON.parse(text); } catch { data = {}; }
|
|
124
|
+
if (!res.ok) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { status: res.status });
|
|
125
|
+
return data;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function loadEntries() {
|
|
129
|
+
try {
|
|
130
|
+
const data = await apiFetch('GET', API_BASE);
|
|
131
|
+
_entries = Array.isArray(data.entries) ? data.entries : [];
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.warn('[feedback] loadEntries failed:', err.message);
|
|
134
|
+
_entries = [];
|
|
135
|
+
showTableMessage(`⚠️ Failed to load entries: ${err.message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Table rendering ─────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
function renderTable() {
|
|
142
|
+
const container = document.getElementById('feedback-table');
|
|
143
|
+
if (!container) return;
|
|
144
|
+
|
|
145
|
+
const filtered = _entries.filter(e => {
|
|
146
|
+
const matchText = !_filterText ||
|
|
147
|
+
(e.title || '').toLowerCase().includes(_filterText) ||
|
|
148
|
+
(e.type || '').toLowerCase().includes(_filterText) ||
|
|
149
|
+
(e.description || '').toLowerCase().includes(_filterText);
|
|
150
|
+
const matchStatus = !_statusFilter || e.status === _statusFilter;
|
|
151
|
+
return matchText && matchStatus;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (filtered.length === 0) {
|
|
155
|
+
container.innerHTML = `<div class="feedback-empty">${
|
|
156
|
+
_entries.length === 0
|
|
157
|
+
? 'No feedback entries. Click <strong>New Entry</strong> to create one.'
|
|
158
|
+
: 'No entries match the current filter.'
|
|
159
|
+
}</div>`;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const rows = filtered.map(e => {
|
|
164
|
+
const sColor = STATUS_COLORS[e.status] || '#8e959e';
|
|
165
|
+
const sevColor = SEV_COLORS[e.severity] || '#8e959e';
|
|
166
|
+
const ts = e.timestamp ? new Date(e.timestamp).toLocaleString() : '—';
|
|
167
|
+
return `<tr class="feedback-row${e.id === _selectedId ? ' selected' : ''}"
|
|
168
|
+
data-fb-action="row-select" data-id="${esc(e.id)}">
|
|
169
|
+
<td class="fb-cell fb-id">${esc(e.id.slice(0, 8))}…</td>
|
|
170
|
+
<td class="fb-cell fb-title">${esc(e.title)}</td>
|
|
171
|
+
<td class="fb-cell fb-type">${esc(e.type)}</td>
|
|
172
|
+
<td class="fb-cell fb-sev" style="color:${sevColor}">${esc(e.severity)}</td>
|
|
173
|
+
<td class="fb-cell fb-status">
|
|
174
|
+
<span class="fb-badge" style="background:${sColor}22;color:${sColor};border:1px solid ${sColor}44">${esc(e.status)}</span>
|
|
175
|
+
</td>
|
|
176
|
+
<td class="fb-cell fb-ts text-dim">${esc(ts)}</td>
|
|
177
|
+
<td class="fb-cell fb-actions" onclick="event.stopPropagation()">
|
|
178
|
+
<button class="action-btn sm" data-fb-action="edit" data-id="${esc(e.id)}" title="Edit">✏️</button>
|
|
179
|
+
<button class="action-btn sm danger" data-fb-action="delete" data-id="${esc(e.id)}" title="Delete">🗑️</button>
|
|
180
|
+
</td>
|
|
181
|
+
</tr>`;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
container.innerHTML = `
|
|
185
|
+
<table class="fb-table">
|
|
186
|
+
<thead>
|
|
187
|
+
<tr>
|
|
188
|
+
<th class="fb-th">ID</th>
|
|
189
|
+
<th class="fb-th">Title</th>
|
|
190
|
+
<th class="fb-th">Type</th>
|
|
191
|
+
<th class="fb-th">Severity</th>
|
|
192
|
+
<th class="fb-th">Status</th>
|
|
193
|
+
<th class="fb-th">Created</th>
|
|
194
|
+
<th class="fb-th">Actions</th>
|
|
195
|
+
</tr>
|
|
196
|
+
</thead>
|
|
197
|
+
<tbody>${rows.join('')}</tbody>
|
|
198
|
+
</table>`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function showTableMessage(msg) {
|
|
202
|
+
const container = document.getElementById('feedback-table');
|
|
203
|
+
if (!container) return;
|
|
204
|
+
const message = document.createElement('div');
|
|
205
|
+
message.className = 'feedback-empty';
|
|
206
|
+
message.textContent = String(msg || '');
|
|
207
|
+
container.replaceChildren(message);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Detail / edit panel ──────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function openCreate() {
|
|
213
|
+
_selectedId = null;
|
|
214
|
+
_editMode = 'create';
|
|
215
|
+
populateForm(null);
|
|
216
|
+
showDetail('New Feedback Entry');
|
|
217
|
+
document.getElementById('feedback-delete-btn').style.display = 'none';
|
|
218
|
+
document.getElementById('feedback-github-btn').style.display = 'none';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function openEdit(id) {
|
|
222
|
+
const entry = _entries.find(e => e.id === id);
|
|
223
|
+
if (!entry) return;
|
|
224
|
+
_selectedId = id;
|
|
225
|
+
_editMode = 'edit';
|
|
226
|
+
populateForm(entry);
|
|
227
|
+
showDetail('Edit Feedback Entry');
|
|
228
|
+
document.getElementById('feedback-delete-btn').style.display = '';
|
|
229
|
+
document.getElementById('feedback-github-btn').style.display = '';
|
|
230
|
+
renderTable(); // update row highlight
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function showDetail(titleText) {
|
|
234
|
+
const detail = document.getElementById('feedback-detail');
|
|
235
|
+
const titleEl = document.getElementById('feedback-detail-title');
|
|
236
|
+
if (detail) detail.classList.remove('hidden');
|
|
237
|
+
if (titleEl) titleEl.textContent = titleText;
|
|
238
|
+
if (detail) detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function closeDetail() {
|
|
242
|
+
_selectedId = null;
|
|
243
|
+
_editMode = null;
|
|
244
|
+
const detail = document.getElementById('feedback-detail');
|
|
245
|
+
if (detail) detail.classList.add('hidden');
|
|
246
|
+
renderTable();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function populateForm(entry) {
|
|
250
|
+
setValue('feedback-entry-title', entry ? entry.title : '');
|
|
251
|
+
setValue('feedback-entry-type', entry ? entry.type : 'bug-report');
|
|
252
|
+
setValue('feedback-entry-severity', entry ? entry.severity : 'medium');
|
|
253
|
+
setValue('feedback-entry-status', entry ? entry.status : 'new');
|
|
254
|
+
setValue('feedback-entry-description', entry ? (entry.description || '') : '');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function readForm() {
|
|
258
|
+
return {
|
|
259
|
+
title: (getValue('feedback-entry-title') || '').trim(),
|
|
260
|
+
type: getValue('feedback-entry-type') || 'other',
|
|
261
|
+
severity: getValue('feedback-entry-severity') || 'medium',
|
|
262
|
+
status: getValue('feedback-entry-status') || 'new',
|
|
263
|
+
description: (getValue('feedback-entry-description') || '').trim(),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── CRUD operations ──────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
async function saveEntry() {
|
|
270
|
+
const data = readForm();
|
|
271
|
+
if (!data.title) {
|
|
272
|
+
alert('Title is required.');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
if (_editMode === 'create') {
|
|
277
|
+
await apiFetch('POST', API_BASE, { type: data.type, severity: data.severity, title: data.title, description: data.description });
|
|
278
|
+
} else if (_editMode === 'edit' && _selectedId) {
|
|
279
|
+
await apiFetch('PATCH', `${API_BASE}/${_selectedId}`, { status: data.status, title: data.title, description: data.description, severity: data.severity });
|
|
280
|
+
}
|
|
281
|
+
await loadEntries();
|
|
282
|
+
closeDetail();
|
|
283
|
+
} catch (err) {
|
|
284
|
+
alert(`Save failed: ${err.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function confirmDelete(id) {
|
|
289
|
+
if (!id) return;
|
|
290
|
+
const entry = _entries.find(e => e.id === id);
|
|
291
|
+
const label = entry ? entry.title : id;
|
|
292
|
+
if (!confirm(`Delete entry: "${label}"?`)) return;
|
|
293
|
+
try {
|
|
294
|
+
await apiFetch('DELETE', `${API_BASE}/${id}`);
|
|
295
|
+
if (_selectedId === id) closeDetail();
|
|
296
|
+
await loadEntries();
|
|
297
|
+
renderTable();
|
|
298
|
+
} catch (err) {
|
|
299
|
+
alert(`Delete failed: ${err.message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── GitHub handoff (client-side only — no server calls, no token) ────────────
|
|
304
|
+
|
|
305
|
+
function openGitHubHandoff() {
|
|
306
|
+
const data = readForm();
|
|
307
|
+
const entry = _selectedId
|
|
308
|
+
? { ..._entries.find(e => e.id === _selectedId), ...data }
|
|
309
|
+
: data;
|
|
310
|
+
const url = buildGitHubIssueUrl(entry);
|
|
311
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
const esc = window.adminUtils.escapeHtml;
|
|
317
|
+
|
|
318
|
+
function setValue(id, val) {
|
|
319
|
+
const el = document.getElementById(id);
|
|
320
|
+
if (el) el.value = val;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function getValue(id) {
|
|
324
|
+
const el = document.getElementById(id);
|
|
325
|
+
return el ? el.value : '';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
})();
|
|
@@ -30,28 +30,130 @@
|
|
|
30
30
|
host.replaceChildren(box);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const SVG_ALLOWED_TAGS = new Map([
|
|
34
|
+
['svg', 'svg'], ['g', 'g'], ['defs', 'defs'], ['style', 'style'], ['title', 'title'], ['desc', 'desc'],
|
|
35
|
+
['path', 'path'], ['rect', 'rect'], ['circle', 'circle'], ['ellipse', 'ellipse'], ['polygon', 'polygon'], ['polyline', 'polyline'], ['line', 'line'],
|
|
36
|
+
['text', 'text'], ['tspan', 'tspan'], ['textpath', 'textPath'],
|
|
37
|
+
['marker', 'marker'], ['pattern', 'pattern'], ['clippath', 'clipPath'], ['mask', 'mask'], ['symbol', 'symbol'], ['use', 'use'],
|
|
38
|
+
['lineargradient', 'linearGradient'], ['radialgradient', 'radialGradient'], ['stop', 'stop']
|
|
39
|
+
]);
|
|
40
|
+
const SVG_ALLOWED_ATTRS = new Set([
|
|
41
|
+
'id', 'class', 'role',
|
|
42
|
+
'xmlns', 'xmlns:xlink', 'xml:space',
|
|
43
|
+
'viewbox', 'preserveaspectratio', 'version',
|
|
44
|
+
'x', 'y', 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'cx', 'cy', 'r', 'rx', 'ry',
|
|
45
|
+
'width', 'height', 'd', 'points', 'pathlength',
|
|
46
|
+
'transform', 'transform-origin',
|
|
47
|
+
'fill', 'fill-opacity', 'fill-rule',
|
|
48
|
+
'stroke', 'stroke-opacity', 'stroke-width', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
|
|
49
|
+
'opacity', 'color',
|
|
50
|
+
'font-family', 'font-size', 'font-style', 'font-weight',
|
|
51
|
+
'text-anchor', 'dominant-baseline', 'alignment-baseline', 'baseline-shift', 'letter-spacing', 'word-spacing',
|
|
52
|
+
'marker-start', 'marker-mid', 'marker-end',
|
|
53
|
+
'markerunits', 'markerwidth', 'markerheight', 'orient', 'refx', 'refy',
|
|
54
|
+
'patternunits', 'patterncontentunits', 'patterntransform',
|
|
55
|
+
'gradientunits', 'gradienttransform', 'spreadmethod',
|
|
56
|
+
'offset', 'stop-color', 'stop-opacity',
|
|
57
|
+
'clip-path', 'clippathunits', 'mask', 'maskunits', 'maskcontentunits', 'filter',
|
|
58
|
+
'href', 'xlink:href', 'style',
|
|
59
|
+
'aria-hidden', 'aria-label', 'aria-labelledby', 'aria-describedby'
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
function hasUnsafeUrlValue(value){
|
|
63
|
+
const text = String(value || '');
|
|
64
|
+
if(!text) return false;
|
|
65
|
+
if(/\b(?:javascript|vbscript|data)\s*:/i.test(text)) return true;
|
|
66
|
+
const urlMatches = text.matchAll(/url\(([^)]*)\)/gi);
|
|
67
|
+
for(const match of urlMatches){
|
|
68
|
+
const inner = String(match[1] || '').trim().replace(/^['"]|['"]$/g, '');
|
|
69
|
+
if(!/^#[-\w:.]+$/i.test(inner)) return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sanitizeSvgStyleText(cssText){
|
|
75
|
+
const css = String(cssText || '');
|
|
76
|
+
if(!css.trim()) return '';
|
|
77
|
+
if(/@import|expression\s*\(|-moz-binding|behavior\s*:|<\/style/i.test(css)) return '';
|
|
78
|
+
if(hasUnsafeUrlValue(css)) return '';
|
|
79
|
+
return css;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sanitizeSvgNode(node){
|
|
83
|
+
if(!node) return null;
|
|
84
|
+
if(node.nodeType === Node.TEXT_NODE){
|
|
85
|
+
return document.createTextNode(node.textContent || '');
|
|
86
|
+
}
|
|
87
|
+
if(node.nodeType !== Node.ELEMENT_NODE) return null;
|
|
88
|
+
const tag = String(node.localName || node.nodeName || '').toLowerCase();
|
|
89
|
+
const canonicalTag = SVG_ALLOWED_TAGS.get(tag);
|
|
90
|
+
if(!canonicalTag) return null;
|
|
91
|
+
const clean = document.createElementNS('http://www.w3.org/2000/svg', canonicalTag);
|
|
92
|
+
Array.from(node.attributes || []).forEach((attr) => {
|
|
93
|
+
const name = String(attr.name || '').toLowerCase();
|
|
94
|
+
if(!name || name.startsWith('on') || !SVG_ALLOWED_ATTRS.has(name)) return;
|
|
95
|
+
const value = attr.value || '';
|
|
96
|
+
if((name === 'href' || name === 'xlink:href') && !/^\s*#[-\w:.]+\s*$/i.test(value)) return;
|
|
97
|
+
if(hasUnsafeUrlValue(value)) return;
|
|
98
|
+
if(name === 'style'){
|
|
99
|
+
const safeStyle = sanitizeSvgStyleText(value);
|
|
100
|
+
if(!safeStyle) return;
|
|
101
|
+
clean.setAttribute(attr.name, safeStyle);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if(name === 'xlink:href'){
|
|
105
|
+
clean.setAttributeNS('http://www.w3.org/1999/xlink', attr.name, value);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
clean.setAttribute(attr.name, value);
|
|
109
|
+
});
|
|
110
|
+
if(tag === 'style'){
|
|
111
|
+
const safeCss = sanitizeSvgStyleText(node.textContent || '');
|
|
112
|
+
if(!safeCss) return null;
|
|
113
|
+
clean.textContent = safeCss;
|
|
114
|
+
return clean;
|
|
115
|
+
}
|
|
116
|
+
Array.from(node.childNodes || []).forEach((child) => {
|
|
117
|
+
const cleanChild = sanitizeSvgNode(child);
|
|
118
|
+
if(cleanChild) clean.appendChild(cleanChild);
|
|
119
|
+
});
|
|
120
|
+
return clean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sanitizeGraphSvg(svgMarkup){
|
|
124
|
+
// Step 1: parse user-supplied (mermaid-rendered) markup. This is the only
|
|
125
|
+
// call to DOMParser.parseFromString in this module; everything below works
|
|
126
|
+
// off allowlist-reconstructed nodes.
|
|
35
127
|
const parsed = new DOMParser().parseFromString(String(svgMarkup || ''), 'image/svg+xml');
|
|
36
128
|
if(parsed.querySelector('parsererror') || parsed.documentElement.tagName.toLowerCase() !== 'svg'){
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
// Step 2: walk the parsed tree and rebuild it as fresh nodes via
|
|
132
|
+
// createElementNS using the SVG_ALLOWED_TAGS / SVG_ALLOWED_ATTRS
|
|
133
|
+
// allowlists. The returned subtree shares no node identity with `parsed`.
|
|
134
|
+
const reconstructed = sanitizeSvgNode(parsed.documentElement);
|
|
135
|
+
if(!reconstructed || reconstructed.tagName.toLowerCase() !== 'svg') return null;
|
|
136
|
+
if(!reconstructed.getAttribute('xmlns')) reconstructed.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
137
|
+
// Step 3: serialize the reconstructed (safe) tree to a string and re-parse
|
|
138
|
+
// it. This conclusively severs any data-flow link from the original
|
|
139
|
+
// untrusted markup so static analyzers see only allowlisted text reach
|
|
140
|
+
// DOM insertion sites.
|
|
141
|
+
const safeMarkup = new XMLSerializer().serializeToString(reconstructed);
|
|
142
|
+
const reparsed = new DOMParser().parseFromString(safeMarkup, 'image/svg+xml');
|
|
143
|
+
if(reparsed.querySelector('parsererror') || reparsed.documentElement.tagName.toLowerCase() !== 'svg'){
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return reparsed.documentElement;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function renderGraphSvg(host, svgMarkup){
|
|
150
|
+
if(!host) return;
|
|
151
|
+
const safeSvg = sanitizeGraphSvg(svgMarkup);
|
|
152
|
+
if(!safeSvg){
|
|
37
153
|
renderGraphTextMessage(host, 'Mermaid render failed:: invalid SVG output');
|
|
38
154
|
return;
|
|
39
155
|
}
|
|
40
|
-
|
|
41
|
-
parsed.querySelectorAll('*').forEach((node) => {
|
|
42
|
-
Array.from(node.attributes).forEach((attr) => {
|
|
43
|
-
const name = attr.name.toLowerCase();
|
|
44
|
-
const value = attr.value || '';
|
|
45
|
-
if(name.startsWith('on')){
|
|
46
|
-
node.removeAttribute(attr.name);
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
if((name === 'href' || name === 'xlink:href') && /^\s*(javascript:|data:)/i.test(value)){
|
|
50
|
-
node.removeAttribute(attr.name);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
host.replaceChildren(document.importNode(parsed.documentElement, true));
|
|
156
|
+
host.replaceChildren(safeSvg);
|
|
55
157
|
}
|
|
56
158
|
|
|
57
159
|
async function reloadGraphMermaid(){
|
|
@@ -266,7 +368,7 @@
|
|
|
266
368
|
} catch(procErr){ setGraphMetaProgress('process-error','a='+attemptId); }
|
|
267
369
|
} else {
|
|
268
370
|
if(target) target.textContent = `(graph unavailable${lastErr?': '+(lastErr.message||lastErr):''})`;
|
|
269
|
-
setGraphMetaProgress('unavailable','err='+(lastErr && (lastErr.message||String(lastErr))||'none'));
|
|
371
|
+
setGraphMetaProgress('unavailable','err='+((lastErr && (lastErr.message||String(lastErr))) || 'none'));
|
|
270
372
|
}
|
|
271
373
|
clearTimeout(__graphReloadWatchdog);
|
|
272
374
|
__graphReloadInFlight = false;
|
|
@@ -5,10 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
// Helper: safe global references (these live on page scope)
|
|
7
7
|
const globals = window;
|
|
8
|
-
|
|
9
|
-
function escapeHtml(s) {
|
|
10
|
-
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
11
|
-
}
|
|
8
|
+
const escapeHtml = window.adminUtils.escapeHtml;
|
|
12
9
|
|
|
13
10
|
function sanitizeHtmlFragment(html) {
|
|
14
11
|
const template = document.createElement('template');
|
|
@@ -54,7 +51,21 @@
|
|
|
54
51
|
if (!signal) return '';
|
|
55
52
|
const colors = { 'outdated': '#f2495c', 'not-relevant': '#ff9830', 'helpful': '#73bf69', 'applied': '#5794f2' };
|
|
56
53
|
const color = colors[signal] || '#888';
|
|
57
|
-
return '<span class="signal-badge" style="background:' + color + '22;color:' + color + ';border:1px solid ' + color + '44;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;">' + signal + '</span>';
|
|
54
|
+
return '<span class="signal-badge" style="background:' + color + '22;color:' + color + ';border:1px solid ' + color + '44;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;">' + escapeHtml(signal) + '</span>';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function wireInstructionListActions(listEl) {
|
|
58
|
+
if (!listEl) return;
|
|
59
|
+
listEl.querySelectorAll('[data-instruction-action]').forEach((button) => {
|
|
60
|
+
if (button.__instructionActionBound) return;
|
|
61
|
+
button.addEventListener('click', () => {
|
|
62
|
+
const action = button.getAttribute('data-instruction-action');
|
|
63
|
+
const instructionName = button.getAttribute('data-instruction-name') || '';
|
|
64
|
+
if (action === 'edit') editInstruction(instructionName);
|
|
65
|
+
if (action === 'delete') deleteInstruction(instructionName);
|
|
66
|
+
});
|
|
67
|
+
button.__instructionActionBound = true;
|
|
68
|
+
});
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
async function loadInstructionCategories() {
|
|
@@ -227,26 +238,29 @@
|
|
|
227
238
|
const comment = usage.lastComment || '';
|
|
228
239
|
const signalHtml = signal ? getSignalBadge(signal) : '<span style="opacity:.4;font-size:10px;">none</span>';
|
|
229
240
|
const commentTip = comment ? ' title="Last comment: ' + escapeHtml(comment.slice(0, 200)) + '"' : '';
|
|
241
|
+
const safeSize = escapeHtml(String(instr.size ?? '0'));
|
|
242
|
+
const safeSizeCategory = escapeHtml(String(instr.sizeCategory || 'unknown'));
|
|
243
|
+
const safeModified = escapeHtml(new Date(instr.mtime).toLocaleString());
|
|
230
244
|
return `
|
|
231
245
|
<div class="instruction-item" data-instruction="${escapedName}">
|
|
232
246
|
<div class="instruction-item-header">
|
|
233
247
|
<div class="instruction-name">${highlightedName}</div>
|
|
234
248
|
<div class="instruction-actions">
|
|
235
|
-
<button class="action-btn"
|
|
236
|
-
<button class="action-btn danger"
|
|
249
|
+
<button class="action-btn" data-instruction-action="edit" data-instruction-name="${escapedName}">✏ Edit</button>
|
|
250
|
+
<button class="action-btn danger" data-instruction-action="delete" data-instruction-name="${escapedName}">🗑 Delete</button>
|
|
237
251
|
</div>
|
|
238
252
|
</div>
|
|
239
253
|
<div class="instruction-meta">
|
|
240
254
|
<div class="meta-chip" title="Category"><span class="chip-label">CAT</span><span class="chip-value">${safeCat}</span></div>
|
|
241
|
-
<div class="meta-chip" title="Size"><span class="chip-label">SIZE</span><span class="chip-value">${
|
|
242
|
-
<div class="meta-chip" title="Last Modified"><span class="chip-label">MTIME</span><span class="chip-value">${
|
|
255
|
+
<div class="meta-chip" title="Size"><span class="chip-label">SIZE</span><span class="chip-value">${safeSize}</span><span class="chip-sub">(${safeSizeCategory})</span></div>
|
|
256
|
+
<div class="meta-chip" title="Last Modified"><span class="chip-label">MTIME</span><span class="chip-value">${safeModified}</span></div>
|
|
243
257
|
<div class="meta-chip" title="Usage Count"><span class="chip-label">USES</span><span class="chip-value">${usageCount}</span></div>
|
|
244
258
|
<div class="meta-chip"${commentTip}><span class="chip-label">SIGNAL</span><span class="chip-value">${signalHtml}</span></div>
|
|
245
259
|
</div>
|
|
246
260
|
<div class="instruction-summary">${highlightedSummary || '<span class="summary-empty">No summary</span>'}</div>
|
|
247
261
|
</div>`;
|
|
248
262
|
}).join('');
|
|
249
|
-
const listEl = document.getElementById('instructions-list'); if(listEl) listEl.innerHTML = rows;
|
|
263
|
+
const listEl = document.getElementById('instructions-list'); if(listEl) { listEl.innerHTML = rows; wireInstructionListActions(listEl); }
|
|
250
264
|
buildInstructionPaginationControls(totalFiltered);
|
|
251
265
|
try { console.debug('[admin.instructions] renderInstructionList: rendered rows=', pageItems.length); } catch(e){}
|
|
252
266
|
try { const dbg = document.getElementById('admin-debug'); if(dbg) dbg.textContent = JSON.stringify({ stage:'renderInstructionList', filtered: totalFiltered, rendered: pageItems.length, page: globals.instructionPage }, null, 2); } catch(e){}
|
|
@@ -487,11 +501,11 @@
|
|
|
487
501
|
return;
|
|
488
502
|
}
|
|
489
503
|
const rows = results.results.map(r=>{
|
|
490
|
-
let safeName = (r.name||
|
|
491
|
-
let safeSnippet = (r.snippet||
|
|
504
|
+
let safeName = escapeHtml(r.name || '');
|
|
505
|
+
let safeSnippet = escapeHtml(r.snippet || '').replace(/\*\*(.+?)\*\*/g,'<mark>$1</mark>');
|
|
492
506
|
safeName = highlightMatch(safeName, trimmed, isRegex);
|
|
493
507
|
safeSnippet = highlightMatch(safeSnippet, trimmed, isRegex);
|
|
494
|
-
const cats = Array.isArray(r.categories) && r.categories.length? r.categories.slice(0,6).map(c =>
|
|
508
|
+
const cats = Array.isArray(r.categories) && r.categories.length ? r.categories.slice(0,6).map(c => escapeHtml(c)).join(', ') : '—';
|
|
495
509
|
return `<div class="instruction-global-result" style="background:#1f2228; border:1px solid #2c3038; border-radius:4px; padding:6px 8px; margin-bottom:6px;">
|
|
496
510
|
<div style="font-weight:600; font-size:12px;">${safeName} <span style="opacity:.55; font-weight:400;">(${cats})</span></div>
|
|
497
511
|
<div style="font-size:11px; white-space:normal;">${safeSnippet}</div>
|
|
@@ -99,11 +99,7 @@
|
|
|
99
99
|
if (el) el.innerHTML = '<div class="info">Log viewer cleared</div>';
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
const div = document.createElement('div');
|
|
104
|
-
div.textContent = text;
|
|
105
|
-
return div.innerHTML;
|
|
106
|
-
}
|
|
102
|
+
const escapeHtml = window.adminUtils.escapeHtml;
|
|
107
103
|
|
|
108
104
|
// Expose functions for staged migration
|
|
109
105
|
window.loadLogs = loadLogs;
|