hac-mcp 1.0.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/static/app.js ADDED
@@ -0,0 +1,650 @@
1
+ // ─── Global tooltip ───────────────────────────────────────────────────────────
2
+ const _gtt = document.getElementById('globalTooltip');
3
+ document.addEventListener('mouseover', e => {
4
+ const el = e.target.closest('[data-tooltip]');
5
+ if (!el) { _gtt.style.opacity = '0'; return; }
6
+ _gtt.textContent = el.dataset.tooltip;
7
+ _gtt.style.opacity = '1';
8
+ });
9
+ document.addEventListener('mousemove', e => {
10
+ if (_gtt.style.opacity !== '1') return;
11
+ const w = _gtt.offsetWidth, pad = 8;
12
+ const x = Math.min(Math.max(e.clientX - w / 2, pad), window.innerWidth - w - pad);
13
+ _gtt.style.left = x + 'px';
14
+ _gtt.style.top = (e.clientY - _gtt.offsetHeight - 10) + 'px';
15
+ });
16
+ document.addEventListener('mouseout', e => {
17
+ if (!e.relatedTarget?.closest('[data-tooltip]')) _gtt.style.opacity = '0';
18
+ });
19
+
20
+ // ─── Theme ────────────────────────────────────────────────────────────────────
21
+ const MOON = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`;
22
+ const SUN = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`;
23
+
24
+ function applyTheme(theme) {
25
+ document.documentElement.dataset.theme = theme;
26
+ document.getElementById('btnTheme').innerHTML = theme === 'light' ? MOON : SUN;
27
+ localStorage.setItem('hac-mcp-theme', theme);
28
+ }
29
+ function toggleTheme() {
30
+ applyTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light');
31
+ }
32
+ applyTheme(localStorage.getItem('hac-mcp-theme') || 'dark');
33
+
34
+ // ─── endpoint label + info card ───────────────────────────────────────────────
35
+ const mcpEndpoint = location.origin + '/mcp/sse';
36
+ const claudeCmd = `claude mcp add --transport sse hac-mcp ${mcpEndpoint}`;
37
+ const configJson =
38
+ `{
39
+ "mcpServers": {
40
+ "hac-mcp": {
41
+ "url": "${mcpEndpoint}"
42
+ }
43
+ }
44
+ }`;
45
+
46
+ document.getElementById('infoEndpoint').textContent = mcpEndpoint;
47
+ document.getElementById('infoClaudeCmd').textContent = claudeCmd;
48
+ document.getElementById('infoJson').textContent = configJson;
49
+ document.getElementById('modalClaudeCmd').textContent = claudeCmd;
50
+ document.getElementById('modalJson').textContent = configJson;
51
+
52
+ // ─── Manifest modal ───────────────────────────────────────────────────────────
53
+ const CATEGORY_META = {
54
+ read: { label: 'Read', cls: 'cat-read' },
55
+ write: { label: 'Write', cls: 'cat-write' },
56
+ utility: { label: 'Utility', cls: 'cat-util' },
57
+ };
58
+
59
+ async function showManifest() {
60
+ document.getElementById('manifestOverlay').classList.add('visible');
61
+ const el = document.getElementById('manifestTools');
62
+ if (el.dataset.loaded === '1') return;
63
+ el.innerHTML = '<div class="empty" style="padding:20px 0">Loading…</div>';
64
+ const manifest = await fetch('/api/manifest').then(r => r.json());
65
+ document.getElementById('manifestTitle').textContent = manifest.name;
66
+ document.getElementById('manifestSubtitle').textContent = manifest.description + ' · v' + manifest.version;
67
+
68
+ const byCategory = {};
69
+ for (const t of manifest.tools) {
70
+ (byCategory[t.category] = byCategory[t.category] || []).push(t);
71
+ }
72
+
73
+ const order = ['utility', 'read', 'write'];
74
+ const sections = order.filter(c => byCategory[c]).map(cat => {
75
+ const meta = CATEGORY_META[cat] || { label: cat, cls: 'cat-util' };
76
+ const items = byCategory[cat].map(t => `
77
+ <div class="manifest-tool">
78
+ <div class="manifest-tool-header">
79
+ <code class="manifest-tool-name">${esc(t.name)}</code>
80
+ <span class="manifest-cat ${meta.cls}">${meta.label}</span>
81
+ </div>
82
+ <p class="manifest-tool-desc">${esc(t.description)}</p>
83
+ ${t.params.length ? `<div class="manifest-params">${t.params.map(p => `
84
+ <span class="manifest-param${p.optional ? ' optional' : ''}">
85
+ <span class="manifest-param-name">${esc(p.name)}</span>
86
+ ${p.description ? `<span class="manifest-param-desc">${esc(p.description)}</span>` : ''}
87
+ </span>`).join('')}</div>` : ''}
88
+ </div>
89
+ `).join('');
90
+ return `<div class="manifest-section">
91
+ <div class="manifest-section-title">${meta.label} Tools</div>
92
+ ${items}
93
+ </div>`;
94
+ }).join('');
95
+
96
+ el.innerHTML = sections;
97
+ el.dataset.loaded = '1';
98
+ }
99
+
100
+ function closeManifest() { document.getElementById('manifestOverlay').classList.remove('visible'); }
101
+ function closeManifestModal(e) { if (e.target === document.getElementById('manifestOverlay')) closeManifest(); }
102
+
103
+ // ─── Onboarding modal ─────────────────────────────────────────────────────────
104
+ function showModal() { document.getElementById('modalOverlay').classList.add('visible'); }
105
+ function dismissModal() {
106
+ document.getElementById('modalOverlay').classList.remove('visible');
107
+ localStorage.setItem('hac-mcp-onboarded', '1');
108
+ }
109
+ function closeModal(e) { if (e.target === document.getElementById('modalOverlay')) dismissModal(); }
110
+
111
+ if (!localStorage.getItem('hac-mcp-onboarded')) showModal();
112
+
113
+ function timeAgo(ms) {
114
+ const s = Math.floor((Date.now() - ms) / 1000);
115
+ if (s < 60) return 'just now';
116
+ const m = Math.floor(s / 60);
117
+ if (m < 60) return `${m}m ago`;
118
+ const h = Math.floor(m / 60);
119
+ if (h < 24) return `${h}h ago`;
120
+ return `${Math.floor(h / 24)}d ago`;
121
+ }
122
+
123
+ async function pollStatus() {
124
+ try {
125
+ const { environmentCount, connectedClients, clients } = await fetch('/api/status').then(r => r.json());
126
+ document.getElementById('pillEnvsLabel').textContent =
127
+ environmentCount === 1 ? '1 environment' : `${environmentCount} environments`;
128
+ document.getElementById('pillClientsLabel').textContent =
129
+ connectedClients === 1 ? '1 client connected' : `${connectedClients} clients connected`;
130
+ document.getElementById('clientDot').className = `status-dot ${connectedClients > 0 ? 'active' : 'inactive'}`;
131
+ document.getElementById('pillEnvs').className = `status-pill ${environmentCount > 0 ? 'active' : ''}`;
132
+ document.getElementById('pillClients').className = `status-pill ${connectedClients > 0 ? 'active' : ''}`;
133
+ document.getElementById('infoSetup').style.display = connectedClients > 0 ? 'none' : '';
134
+ const clientList = document.getElementById('clientList');
135
+ if (clients && clients.length > 0) {
136
+ clientList.style.display = 'flex';
137
+ clientList.innerHTML = clients.map((c, i) => {
138
+ const num = `<span class="client-num">Client #${i + 1}</span>`;
139
+ if (!c?.version) {
140
+ const since = c?.connectedAt ? `<span class="client-since">Connected ${timeAgo(c.connectedAt)}</span>` : '';
141
+ const calls = c?.toolCalls > 0 ? `<span class="client-calls" data-tooltip="Number of tool calls made by this client since connecting">${c.toolCalls} call${c.toolCalls !== 1 ? 's' : ''}</span>` : '';
142
+ return `<div class="client-card">
143
+ <div class="client-card-meta">${num}${since}</div>
144
+ <div class="client-card-header"><span class="client-name" style="color:var(--text3)">Client connected but information could not be gathered. Try restarting the client.</span></div>
145
+ ${calls ? `<div class="client-caps">${calls}</div>` : ''}
146
+ </div>`;
147
+ }
148
+ const v = c.version;
149
+ const desc = v.description ? `<div class="client-desc">${v.description}</div>` : '';
150
+ const link = v.websiteUrl ? `<a class="client-link" href="${v.websiteUrl}" target="_blank" rel="noopener">${v.websiteUrl}</a>` : '';
151
+ const since = c.connectedAt ? `<span class="client-since">Connected ${timeAgo(c.connectedAt)}</span>` : '';
152
+ const calls = c.toolCalls > 0 ? `<span class="client-calls" data-tooltip="Number of tool calls made by this client since connecting">${c.toolCalls} call${c.toolCalls !== 1 ? 's' : ''}</span>` : '';
153
+ return `<div class="client-card">
154
+ <div class="client-card-meta">${num}${since}</div>
155
+ <div class="client-card-header">
156
+ <span class="client-name">${v.title || v.name}</span>
157
+ <span class="client-version">v${v.version}</span>
158
+ </div>
159
+ ${desc}
160
+ ${link}
161
+ ${calls ? `<div class="client-caps">${calls}</div>` : ''}
162
+ </div>`;
163
+ }).join('');
164
+ } else {
165
+ clientList.style.display = 'none';
166
+ clientList.innerHTML = '';
167
+ }
168
+ } catch {}
169
+ }
170
+ pollStatus();
171
+ setInterval(pollStatus, 5000);
172
+
173
+ // ─── Environment management ───────────────────────────────────────────────────
174
+ let envs = [];
175
+ const connStatus = {}; // envId → 'unknown' | 'testing' | 'ok' | 'err'
176
+ const connError = {}; // envId → error string
177
+
178
+ async function load() {
179
+ envs = await fetch('/api/environments').then(r => r.json());
180
+ render();
181
+ pollStatus();
182
+ envs.forEach(e => testConn(e.id));
183
+ }
184
+
185
+ function connBadge(id) {
186
+ const s = connStatus[id] || 'unknown';
187
+ const labels = { unknown: 'Unknown', testing: 'Testing…', ok: 'Connected', err: 'Failed' };
188
+ const tooltips = {
189
+ unknown: 'Connection not yet tested',
190
+ testing: 'Testing connection to this environment',
191
+ ok: 'Successfully authenticated with HAC',
192
+ err: connError[id] || 'Failed to connect to this environment',
193
+ };
194
+ return `<span class="conn-status ${s}" data-tooltip="${esc(tooltips[s])}" style="cursor:help;user-select:none"><span class="conn-dot"></span>${labels[s]}</span>`;
195
+ }
196
+
197
+ function render() {
198
+ const el = document.getElementById('envList');
199
+ document.getElementById('btnAddEnv').classList.toggle('btn-pulse', !envs.length);
200
+ if (!envs.length) {
201
+ el.innerHTML = '<div class="empty">No environments configured yet.<br/>Click <strong>+ Add Environment</strong> to connect your first HAC instance.</div>';
202
+ return;
203
+ }
204
+ el.innerHTML = envs.map(e => `
205
+ <div class="env-item" id="env-${e.id}">
206
+ <div class="env-dot ${connStatus[e.id] === 'ok' ? 'active' : connStatus[e.id] === 'testing' ? 'testing' : 'inactive'}" id="dot-${e.id}"></div>
207
+ <div class="env-info">
208
+ <div class="env-name">${esc(e.name)}</div>
209
+ <div class="env-url">${esc(e.url)}</div>
210
+ ${e.description ? `<div class="env-desc">${esc(e.description)}</div>` : ''}
211
+ <div class="env-badges">
212
+ ${e.dbType ? `<span class="badge db" data-tooltip="Database type used by this environment" style="cursor:help;user-select:none"><svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg" style="display:inline-block;vertical-align:middle;margin-right:3px;margin-top:-1px"><ellipse cx="4.5" cy="2" rx="3.5" ry="1.3" stroke="currentColor" stroke-width="1"/><path d="M1 2v5c0 .72 1.57 1.3 3.5 1.3S8 7.72 8 7V2" stroke="currentColor" stroke-width="1"/><path d="M1 4.5c0 .72 1.57 1.3 3.5 1.3S8 5.22 8 4.5" stroke="currentColor" stroke-width="1"/></svg>${esc(e.dbType)}</span>` : ''}
213
+ <span class="badge ${e.allowFlexSearch ? 'on':'off'}" data-tooltip="FlexibleSearch queries ${e.allowFlexSearch ? 'are allowed' : 'are disabled'} on this environment" style="cursor:help;user-select:none">FLEX ${e.allowFlexSearch ? 'ON':'OFF'}</span>
214
+ <span class="badge ${e.allowImpexImport ? 'on':'off'}" data-tooltip="ImpEx import ${e.allowImpexImport ? 'is allowed' : 'is disabled'} on this environment" style="cursor:help;user-select:none">IMPEX ${e.allowImpexImport ? 'ON':'OFF'}</span>
215
+ <span class="badge ${e.allowGroovyExecution ? 'on':'off'}" data-tooltip="Groovy script execution ${e.allowGroovyExecution ? 'is allowed' : 'is disabled'} on this environment" style="cursor:help;user-select:none">GROOVY ${e.allowGroovyExecution ? 'ON':'OFF'}</span>
216
+ <span class="badge ${e.allowGroovyCommitMode !== false ? 'on':'off'}" data-tooltip="Groovy commit mode ${e.allowGroovyCommitMode !== false ? 'is allowed' : 'is disabled'} - when enabled scripts can persist database changes" style="cursor:help;user-select:none">COMMIT ${e.allowGroovyCommitMode !== false ? 'ON':'OFF'}</span>
217
+ <span class="badge ${e.allowReadProperty !== false ? 'on':'off'}" data-tooltip="Reading HAC properties ${e.allowReadProperty !== false ? 'is allowed' : 'is disabled'} on this environment" style="cursor:help;user-select:none">PROPS ${e.allowReadProperty !== false ? 'ON':'OFF'}</span>
218
+ </div>
219
+ </div>
220
+ <div class="env-actions">
221
+ ${connBadge(e.id)}
222
+ <button class="btn-edit btn-sm" onclick="testConn('${e.id}')" id="testbtn-${e.id}" ${connStatus[e.id] === 'ok' || connStatus[e.id] === 'testing' ? 'disabled' : ''}>Test</button>
223
+ <button class="btn-edit btn-sm" onclick="openForm('${e.id}')">Edit</button>
224
+ <button class="btn-del btn-sm" onclick="del('${e.id}')">Delete</button>
225
+ </div>
226
+ </div>
227
+ `).join('');
228
+ }
229
+
230
+ function updateConnBadge(id) {
231
+ const item = document.getElementById('env-' + id);
232
+ if (!item) return;
233
+ const badge = item.querySelector('.conn-status');
234
+ if (badge) badge.outerHTML = connBadge(id);
235
+ const dot = document.getElementById('dot-' + id);
236
+ if (dot) {
237
+ const s = connStatus[id];
238
+ dot.className = `env-dot ${s === 'ok' ? 'active' : s === 'testing' ? 'testing' : 'inactive'}`;
239
+ }
240
+ const btn = document.getElementById('testbtn-' + id);
241
+ if (btn) btn.disabled = connStatus[id] === 'ok' || connStatus[id] === 'testing';
242
+ }
243
+
244
+ async function testConn(id) {
245
+ connStatus[id] = 'testing';
246
+ updateConnBadge(id);
247
+ const res = await fetch(`/api/environments/${id}/test`, { method: 'POST' });
248
+ const data = await res.json();
249
+ connStatus[id] = data.ok ? 'ok' : 'err';
250
+ connError[id] = data.error || '';
251
+ updateConnBadge(id);
252
+ }
253
+
254
+ function openForm(id) {
255
+ const e = id ? envs.find(x => x.id === id) : null;
256
+ document.getElementById('formOverlay').classList.add('visible');
257
+ if (!e) typewriter.start(); else typewriter.stop();
258
+ updateSaveBtn();
259
+ document.getElementById('formTitle').textContent = e ? 'Edit Environment' : 'Add Environment';
260
+ document.getElementById('editId').value = e?.id ?? '';
261
+ document.getElementById('fName').value = e?.name ?? '';
262
+ document.getElementById('fDesc').value = e?.description ?? '';
263
+ document.getElementById('fUrl').value = e?.url ?? '';
264
+ document.getElementById('fUser').value = e?.username ?? '';
265
+ document.getElementById('fPass').value = e?.password ?? '';
266
+ document.getElementById('fFlex').checked = e ? e.allowFlexSearch : true;
267
+ document.getElementById('fImpex').checked = e ? e.allowImpexImport : false;
268
+ document.getElementById('fGroovy').checked = e ? e.allowGroovyExecution : false;
269
+ document.getElementById('fGroovyCommit').checked = e ? e.allowGroovyCommitMode !== false : false;
270
+ toggleGroovyCommit(document.getElementById('fGroovy').checked);
271
+ document.getElementById('fReadProperty').checked = e ? e.allowReadProperty !== false : true;
272
+ document.getElementById('fDbType').value = e?.dbType ?? 'MSSQL';
273
+ }
274
+
275
+ // ─── Auto URL test ────────────────────────────────────────────────────────────
276
+ let urlTestTimer;
277
+ function scheduleUrlTest() {
278
+ clearTimeout(urlTestTimer);
279
+ const url = document.getElementById('fUrl').value.trim();
280
+ const user = document.getElementById('fUser').value.trim();
281
+ const pass = document.getElementById('fPass').value;
282
+ const el = document.getElementById('urlTestStatus');
283
+ const urlInput = document.getElementById('fUrl');
284
+ if (!url) { el.style.display = 'none'; urlInput.style.borderRadius = ''; return; }
285
+ urlInput.style.borderRadius = '6px 6px 0 0';
286
+ if (!user || !pass) {
287
+ el.style.display = '';
288
+ el.className = 'url-status hint';
289
+ el.textContent = 'Enter username and password to test connection';
290
+ return;
291
+ }
292
+ el.style.display = '';
293
+ el.className = 'url-status testing';
294
+ el.textContent = 'Testing connection…';
295
+ urlTestTimer = setTimeout(async () => {
296
+ try {
297
+ const res = await fetch('/api/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, username: user, password: pass }) });
298
+ const data = await res.json();
299
+ if (data.ok) {
300
+ el.className = 'url-status ok';
301
+ el.textContent = '✓ Connected successfully';
302
+ } else if (data.type === 'auth') {
303
+ el.className = 'url-status warn';
304
+ el.textContent = '⚠ URL reachable but credentials are wrong';
305
+ } else {
306
+ el.className = 'url-status err';
307
+ const msg = data.error || '';
308
+ const friendly = msg.includes('ENOTFOUND') ? '✗ Host not found - check the URL'
309
+ : msg.includes('ECONNREFUSED') ? '✗ Connection refused - server may be down'
310
+ : msg.includes('ETIMEDOUT') || msg.includes('ESOCKETTIMEDOUT') ? '✗ Connection timed out'
311
+ : msg.includes('ECONNRESET') ? '✗ Connection reset by server'
312
+ : msg.includes('certificate') || msg.includes('SSL') || msg.includes('TLS') ? '✗ SSL/TLS error - try http:// instead'
313
+ : '✗ Could not reach server';
314
+ el.textContent = friendly;
315
+ }
316
+ } catch {
317
+ el.className = 'url-status err';
318
+ el.textContent = '✗ Could not reach server';
319
+ }
320
+ }, 1200);
321
+ }
322
+
323
+ ['fUrl','fUser','fPass'].forEach(id => document.getElementById(id).addEventListener('input', scheduleUrlTest));
324
+
325
+ function toggleGroovyCommit(enabled) {
326
+ const cb = document.getElementById('fGroovyCommit');
327
+ cb.disabled = !enabled;
328
+ document.getElementById('groovyCommitRow').classList.toggle('disabled', !enabled);
329
+ if (!enabled) { cb.checked = false; updateGroovyNote(); }
330
+ else updateGroovyNote();
331
+ }
332
+
333
+ function updateGroovyNote() {
334
+ const groovyOn = document.getElementById('fGroovy').checked;
335
+ const commitOn = document.getElementById('fGroovyCommit').checked;
336
+ document.getElementById('groovyNote').style.display = groovyOn && !commitOn ? '' : 'none';
337
+ }
338
+
339
+ function closeForm() {
340
+ document.getElementById('formOverlay').classList.remove('visible');
341
+ typewriter.stop();
342
+ clearFieldErrors();
343
+ }
344
+ function closeFormModal(e) {
345
+ if (e.target === document.getElementById('formOverlay')) closeForm();
346
+ }
347
+
348
+ document.addEventListener('keydown', e => {
349
+ if (e.key !== 'Escape') return;
350
+ if (document.getElementById('formOverlay').classList.contains('visible')) closeForm();
351
+ else if (document.getElementById('manifestOverlay').classList.contains('visible')) closeManifest();
352
+ else if (document.getElementById('modalOverlay').classList.contains('visible')) dismissModal();
353
+ });
354
+
355
+ function setFieldError(id, msg) {
356
+ const el = document.getElementById(id);
357
+ const input = el.previousElementSibling.tagName === 'DIV' ? el.previousElementSibling.previousElementSibling : el.previousElementSibling;
358
+ el.textContent = msg;
359
+ el.style.display = msg ? '' : 'none';
360
+ input?.classList.toggle('input-err', !!msg);
361
+ }
362
+
363
+ function clearFieldErrors() {
364
+ ['errName','errUrl','errUser','errPass'].forEach(id => setFieldError(id, ''));
365
+ }
366
+
367
+ function updateSaveBtn() {
368
+ const ok = document.getElementById('fName').value.trim()
369
+ && document.getElementById('fUrl').value.trim()
370
+ && document.getElementById('fUser').value.trim()
371
+ && document.getElementById('fPass').value;
372
+ document.getElementById('btnSave').disabled = !ok;
373
+ }
374
+
375
+ ['fName','fUrl','fUser','fPass'].forEach(id => {
376
+ document.getElementById(id).addEventListener('input', () => {
377
+ const errMap = { fName:'errName', fUrl:'errUrl', fUser:'errUser', fPass:'errPass' };
378
+ setFieldError(errMap[id], '');
379
+ updateSaveBtn();
380
+ });
381
+ });
382
+
383
+ async function saveEnv() {
384
+ const id = document.getElementById('editId').value;
385
+ const name = document.getElementById('fName').value.trim();
386
+ const rawUrl = document.getElementById('fUrl').value.trim();
387
+ const username = document.getElementById('fUser').value.trim();
388
+ const password = document.getElementById('fPass').value;
389
+
390
+ clearFieldErrors();
391
+ let valid = true;
392
+ if (!name) { setFieldError('errName', 'Name is required'); valid = false; }
393
+ if (!rawUrl) { setFieldError('errUrl', 'HAC URL is required'); valid = false; }
394
+ if (!username) { setFieldError('errUser', 'Username is required'); valid = false; }
395
+ if (!password) { setFieldError('errPass', 'Password is required'); valid = false; }
396
+ if (!valid) return;
397
+
398
+ const data = {
399
+ name,
400
+ description: document.getElementById('fDesc').value.trim(),
401
+ url: /^https?:\/\//i.test(rawUrl) ? rawUrl : 'https://' + rawUrl,
402
+ username,
403
+ password,
404
+ allowFlexSearch: document.getElementById('fFlex').checked,
405
+ allowImpexImport: document.getElementById('fImpex').checked,
406
+ allowGroovyExecution: document.getElementById('fGroovy').checked,
407
+ allowGroovyCommitMode: document.getElementById('fGroovyCommit').checked,
408
+ allowReadProperty: document.getElementById('fReadProperty').checked,
409
+ dbType: document.getElementById('fDbType').value || null,
410
+ };
411
+ const res = await fetch(id ? `/api/environments/${id}` : '/api/environments', {
412
+ method: id ? 'PUT' : 'POST',
413
+ headers: { 'Content-Type': 'application/json' },
414
+ body: JSON.stringify(data),
415
+ });
416
+ if (res.ok) {
417
+ const saved = await res.json();
418
+ toast(id ? 'Saved' : 'Environment added', 'ok');
419
+ closeForm();
420
+ await load();
421
+ testConn(saved.id); // auto-test in background, don't await
422
+ } else { const e = await res.json(); toast(e.error || 'Error saving', 'err'); }
423
+ }
424
+
425
+ async function del(id) {
426
+ const e = envs.find(x => x.id === id);
427
+ if (!confirm(`Delete "${e?.name}"?`)) return;
428
+ await fetch(`/api/environments/${id}`, { method: 'DELETE' });
429
+ toast('Deleted', 'ok');
430
+ load();
431
+ }
432
+
433
+ // ─── HAC request log (SSE) ───────────────────────────────────────────────────
434
+ function clearLog(listId) {
435
+ const el = document.getElementById(listId);
436
+ el.innerHTML = `<div class="empty" style="padding:20px">Cleared.</div>`;
437
+ }
438
+
439
+ const BADGE = { http: 'HTTP', info: 'INFO', ok: 'OK', error: 'ERR' };
440
+
441
+ function appendHacLog(entry) {
442
+ const list = document.getElementById('hacLogList');
443
+ const empty = list.querySelector('.empty');
444
+ if (empty) empty.remove();
445
+
446
+ const time = new Date(entry.ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
447
+ const line = document.createElement('div');
448
+ line.className = `hac-log-line ${entry.level}`;
449
+ line.innerHTML =
450
+ `<span class="hac-ts">${time}</span>` +
451
+ `<span class="hac-badge">[${BADGE[entry.level] || entry.level}]</span>` +
452
+ `<span class="hac-msg">${esc(entry.msg)}</span>`;
453
+ list.appendChild(line);
454
+ list.scrollTop = list.scrollHeight;
455
+
456
+ // cap at 50 lines
457
+ const lines = list.querySelectorAll('.hac-log-line');
458
+ if (lines.length > 50) lines[0].remove();
459
+ }
460
+
461
+ const hacEs = new EventSource('/api/hac-log');
462
+ hacEs.onopen = () => document.getElementById('hacLogDot').classList.add('connected');
463
+ hacEs.onerror = () => document.getElementById('hacLogDot').classList.remove('connected');
464
+ hacEs.onmessage = e => appendHacLog(JSON.parse(e.data));
465
+
466
+ // ─── MCP activity log (SSE) ───────────────────────────────────────────────────
467
+ const TOOL_META = {
468
+
469
+ flexible_search: { label: 'FLEX', cls: 'flex' },
470
+ impex_import: { label: 'IMPEX', cls: 'impex' },
471
+ list_environments: { label: 'LIST', cls: 'list' },
472
+ };
473
+
474
+
475
+ const runningEntries = new Map(); // server runId → DOM element id
476
+
477
+ function appendLogEntry(entry) {
478
+ const list = document.getElementById('logList');
479
+ const empty = list.querySelector('.empty');
480
+ if (empty) empty.remove();
481
+
482
+ const time = new Date(entry.ts).toLocaleTimeString();
483
+ const domId = 'log-' + entry.ts + '-' + Math.random().toString(36).slice(2);
484
+ const item = document.createElement('div');
485
+
486
+ if (entry.status === 'system') {
487
+ item.className = 'log-entry log-system';
488
+ item.id = domId;
489
+ item.innerHTML = `
490
+ <div class="log-summary log-summary-system">
491
+ <span class="log-sys-dot"></span>
492
+ <span class="log-client">${esc(entry.client || '?')}</span>
493
+ <span class="log-preview">${esc(entry.preview || '')}</span>
494
+ <span class="log-time">${time}</span>
495
+ </div>
496
+ `;
497
+ list.insertBefore(item, list.firstChild);
498
+ const items = list.querySelectorAll('.log-entry');
499
+ if (items.length > 50) items[items.length - 1].remove();
500
+ return;
501
+ }
502
+
503
+ const isRunning = entry.status === 'running';
504
+ const meta = TOOL_META[entry.tool] || { label: entry.tool?.toUpperCase() ?? '?', cls: 'list' };
505
+ const isErr = entry.isError;
506
+ const toolCls = isErr ? 'err' : meta.cls;
507
+ const toolLabel = isErr ? 'ERR' : meta.label;
508
+ const envName = entry.envName ? esc(entry.envName) : '';
509
+ const preview = esc(entry.preview || '');
510
+ const client = entry.client ? esc(entry.client) : '';
511
+
512
+ // If this is a completion event for a running entry, update it in place
513
+ if (!isRunning && entry.id && runningEntries.has(entry.id)) {
514
+ const domId = runningEntries.get(entry.id);
515
+ runningEntries.delete(entry.id);
516
+ const existing = document.getElementById(domId);
517
+ if (existing) {
518
+ existing.classList.remove('running');
519
+ if (isErr) existing.classList.add('error');
520
+ existing.querySelector('.log-tool').className = `log-tool ${toolCls}`;
521
+ existing.querySelector('.log-tool').textContent = toolLabel;
522
+ existing.querySelector('.log-preview').textContent = entry.preview || '';
523
+ existing.querySelector('.log-time').textContent = time;
524
+ existing.querySelector('.log-detail pre').textContent = entry.detail || '';
525
+ return;
526
+ }
527
+ }
528
+
529
+ item.className = 'log-entry' + (isRunning ? ' running' : '') + (isErr ? ' error' : '');
530
+ item.id = domId;
531
+ item.innerHTML = `
532
+ <div class="log-summary" onclick="toggleLog('${domId}')">
533
+ <span class="log-chevron">▶</span>
534
+ ${client ? `<span class="log-client">${client}</span>` : ''}
535
+ <span class="log-tool ${toolCls}">${toolLabel}</span>
536
+ ${envName ? `<span class="log-env">${envName}</span>` : ''}
537
+ <span class="log-preview">${preview}</span>
538
+ <span class="log-time">${time}</span>
539
+ </div>
540
+ <div class="log-detail"><pre>${esc(entry.detail || '')}</pre></div>
541
+ `;
542
+
543
+ if (isRunning && entry.id) runningEntries.set(entry.id, domId);
544
+
545
+ list.insertBefore(item, list.firstChild);
546
+ const items = list.querySelectorAll('.log-entry');
547
+ if (items.length > 50) items[items.length - 1].remove();
548
+ }
549
+
550
+ function toggleLog(id) {
551
+ document.getElementById(id)?.classList.toggle('open');
552
+ }
553
+
554
+ const es = new EventSource('/api/mcp-log');
555
+ es.onopen = () => document.getElementById('logDot').classList.add('connected');
556
+ es.onerror = () => document.getElementById('logDot').classList.remove('connected');
557
+ es.onmessage = e => appendLogEntry(JSON.parse(e.data));
558
+
559
+ // ─── Typewriter placeholders ──────────────────────────────────────────────────
560
+ const scenarios = [
561
+ {
562
+ name: 'Production',
563
+ desc: 'Live environment - never run Groovy with commit here',
564
+ url: 'https://commerce.example.com:9002',
565
+ },
566
+ {
567
+ name: 'Local DEV',
568
+ desc: 'Local dev instance, safe to experiment freely',
569
+ url: 'https://localhost:9002',
570
+ },
571
+ {
572
+ name: 'Staging S1',
573
+ desc: 'S1 staging - shared with QA, handle ImpEx with care',
574
+ url: 'https://staging.example.com:9002',
575
+ },
576
+ {
577
+ name: 'QA Environment',
578
+ desc: 'QA instance - owned by the testing team, ask before importing',
579
+ url: 'https://qa.commerce.internal:9002',
580
+ },
581
+ {
582
+ name: 'Pre-production',
583
+ desc: 'Pre-prod mirror - always validate here before pushing live',
584
+ url: 'https://preprod.example.com:9002',
585
+ },
586
+ ];
587
+
588
+ function charTypewriter(inputs, getValues) {
589
+ let si = 0, ci = 0, deleting = false;
590
+ let timer, paused = true;
591
+
592
+ function tick() {
593
+ if (paused) return;
594
+ const values = getValues(si);
595
+ const longest = Math.max(...values.map(v => v.length));
596
+
597
+ if (!deleting) {
598
+ ci++;
599
+ inputs.forEach((input, i) => { input.placeholder = values[i].slice(0, ci); });
600
+ if (ci >= longest) { timer = setTimeout(() => { deleting = true; tick(); }, 1600); return; }
601
+ timer = setTimeout(tick, 30 + Math.random() * 25);
602
+ } else {
603
+ ci--;
604
+ inputs.forEach((input, i) => { input.placeholder = values[i].slice(0, ci); });
605
+ if (ci === 0) { deleting = false; si = (si + 1) % scenarios.length; timer = setTimeout(tick, 300); return; }
606
+ timer = setTimeout(tick, 18);
607
+ }
608
+ }
609
+
610
+ inputs.forEach(input => {
611
+ input.addEventListener('blur', () => { if (!input.value && !paused) { ci = 0; deleting = false; tick(); } });
612
+ });
613
+
614
+ return {
615
+ start() { paused = false; ci = 0; deleting = false; clearTimeout(timer); tick(); },
616
+ stop() { paused = true; clearTimeout(timer); inputs.forEach(i => { i.placeholder = ''; }); },
617
+ };
618
+ }
619
+
620
+ const fName = document.getElementById('fName');
621
+ const fDesc = document.getElementById('fDesc');
622
+ const fUrl = document.getElementById('fUrl');
623
+
624
+ const typewriter = charTypewriter([fName, fDesc, fUrl], i => [
625
+ scenarios[i].name,
626
+ scenarios[i].desc,
627
+ scenarios[i].url,
628
+ ]);
629
+
630
+ // ─── helpers ──────────────────────────────────────────────────────────────────
631
+ function toast(msg, type) {
632
+ const el = document.getElementById('toast');
633
+ el.textContent = msg;
634
+ el.className = 'toast show ' + type;
635
+ setTimeout(() => el.classList.remove('show'), 2500);
636
+ }
637
+
638
+ async function copyEl(id) {
639
+ const el = document.getElementById(id);
640
+ await navigator.clipboard.writeText(el.textContent);
641
+ const btn = el.closest('.copyable').querySelector('.copy-btn');
642
+ btn.textContent = 'Copied!';
643
+ setTimeout(() => btn.textContent = 'Copy', 1500);
644
+ }
645
+
646
+ function esc(s) {
647
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
648
+ }
649
+
650
+ load();