vibeops-tracker 0.1.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/AUTHORS +10 -0
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/bin/cli.mjs +75 -0
- package/lib/api.mjs +140 -0
- package/lib/data-dir.mjs +46 -0
- package/lib/prompt.mjs +96 -0
- package/lib/store.mjs +569 -0
- package/mcp-server.mjs +247 -0
- package/package.json +62 -0
- package/public/app.js +733 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.svg +56 -0
- package/public/help.html +214 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +56 -0
- package/public/index.html +33 -0
- package/public/manifest.webmanifest +12 -0
- package/public/styles.css +420 -0
- package/public/widget.js +554 -0
- package/server.mjs +75 -0
package/public/widget.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/* issue-tracker embeddable capture widget.
|
|
2
|
+
* Install: <script src="http://localhost:4400/widget.js" data-project="<key>" defer></script>
|
|
3
|
+
* Optional: window.IssueTracker.configure({ context: () => ({ ...appState }) })
|
|
4
|
+
* The widget must never break the host app: every entry point is wrapped.
|
|
5
|
+
*/
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
var BUFFER_CAP = 10;
|
|
10
|
+
var TYPES = ['bug', 'improvement', 'feature', 'other'];
|
|
11
|
+
|
|
12
|
+
function safe(fn) {
|
|
13
|
+
return function () {
|
|
14
|
+
try {
|
|
15
|
+
return fn.apply(this, arguments);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
try {
|
|
18
|
+
console.warn('[issue-tracker]', err);
|
|
19
|
+
} catch (_) {}
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var scriptTag = document.currentScript;
|
|
25
|
+
if (!scriptTag || !scriptTag.src || !scriptTag.getAttribute('data-project')) {
|
|
26
|
+
scriptTag = null;
|
|
27
|
+
var candidates = document.querySelectorAll('script[data-project][src]');
|
|
28
|
+
for (var ci = 0; ci < candidates.length; ci++) {
|
|
29
|
+
try {
|
|
30
|
+
if (new URL(candidates[ci].src).pathname === '/widget.js') {
|
|
31
|
+
scriptTag = candidates[ci];
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
} catch (e) {}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!scriptTag || !scriptTag.src) {
|
|
38
|
+
console.warn('[issue-tracker] widget script tag not found; not mounting');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
var project = scriptTag.getAttribute('data-project');
|
|
42
|
+
var endpoint = new URL(scriptTag.src).origin;
|
|
43
|
+
if (!project) {
|
|
44
|
+
console.warn('[issue-tracker] data-project attribute missing; not mounting');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A second copy of this script (double tag, SPA re-injection) must not
|
|
49
|
+
// double-wrap fetch or double-mount.
|
|
50
|
+
if (window.__issueTrackerWidgetLoaded) return;
|
|
51
|
+
window.__issueTrackerWidgetLoaded = true;
|
|
52
|
+
|
|
53
|
+
var state = {
|
|
54
|
+
contextProvider: null,
|
|
55
|
+
clickBreadcrumbs: [],
|
|
56
|
+
fetchBreadcrumbs: [],
|
|
57
|
+
recentErrors: [],
|
|
58
|
+
recentFetchFailures: [],
|
|
59
|
+
selectedText: '',
|
|
60
|
+
selectedType: 'bug',
|
|
61
|
+
selectedSeverity: 3,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function push(buffer, entry) {
|
|
65
|
+
buffer.push(entry);
|
|
66
|
+
if (buffer.length > BUFFER_CAP) buffer.shift();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isWidgetNode(node) {
|
|
70
|
+
return !!(node && node.closest && node.closest('[data-issue-tracker-ui]'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- ring buffers -------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
document.addEventListener(
|
|
76
|
+
'click',
|
|
77
|
+
safe(function (e) {
|
|
78
|
+
var t = e.target;
|
|
79
|
+
if (!t || isWidgetNode(t)) return;
|
|
80
|
+
var dataAttrs = {};
|
|
81
|
+
if (t.attributes) {
|
|
82
|
+
for (var i = 0; i < t.attributes.length; i++) {
|
|
83
|
+
var a = t.attributes[i];
|
|
84
|
+
if (a.name.indexOf('data-') === 0) dataAttrs[a.name] = a.value;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
push(state.clickBreadcrumbs, {
|
|
88
|
+
ts: new Date().toISOString(),
|
|
89
|
+
tag: t.tagName,
|
|
90
|
+
id: t.id || null,
|
|
91
|
+
cls: t.className && t.className.slice ? t.className.slice(0, 80) : null,
|
|
92
|
+
text: (t.textContent || '').trim().slice(0, 60),
|
|
93
|
+
dataAttrs: dataAttrs,
|
|
94
|
+
});
|
|
95
|
+
}),
|
|
96
|
+
true
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
var origFetch = window.fetch ? window.fetch.bind(window) : null;
|
|
100
|
+
if (origFetch) {
|
|
101
|
+
window.fetch = function (input, init) {
|
|
102
|
+
var url = '';
|
|
103
|
+
try {
|
|
104
|
+
url = typeof input === 'string' ? input : input && input.url ? input.url : String(input);
|
|
105
|
+
} catch (_) {}
|
|
106
|
+
var method = (init && init.method) || (input && input.method) || 'GET';
|
|
107
|
+
var started = Date.now();
|
|
108
|
+
var result = origFetch(input, init);
|
|
109
|
+
try {
|
|
110
|
+
if (url.indexOf(endpoint) !== 0) {
|
|
111
|
+
result.then(
|
|
112
|
+
safe(function (res) {
|
|
113
|
+
var entry = {
|
|
114
|
+
ts: new Date().toISOString(),
|
|
115
|
+
method: method,
|
|
116
|
+
url: String(url).slice(0, 200),
|
|
117
|
+
status: res.status,
|
|
118
|
+
ms: Date.now() - started,
|
|
119
|
+
};
|
|
120
|
+
push(state.fetchBreadcrumbs, entry);
|
|
121
|
+
if (res.status >= 400) push(state.recentFetchFailures, entry);
|
|
122
|
+
}),
|
|
123
|
+
safe(function (err) {
|
|
124
|
+
push(state.fetchBreadcrumbs, {
|
|
125
|
+
ts: new Date().toISOString(),
|
|
126
|
+
method: method,
|
|
127
|
+
url: String(url).slice(0, 200),
|
|
128
|
+
status: 0,
|
|
129
|
+
error: String((err && err.message) || err),
|
|
130
|
+
ms: Date.now() - started,
|
|
131
|
+
});
|
|
132
|
+
})
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
} catch (_) {}
|
|
136
|
+
return result;
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
window.addEventListener(
|
|
141
|
+
'error',
|
|
142
|
+
safe(function (e) {
|
|
143
|
+
push(state.recentErrors, {
|
|
144
|
+
ts: new Date().toISOString(),
|
|
145
|
+
message: String(e.message || (e.error && e.error.message) || 'unknown error'),
|
|
146
|
+
source: e.filename ? e.filename + ':' + e.lineno : null,
|
|
147
|
+
stack: e.error && e.error.stack ? String(e.error.stack).slice(0, 600) : null,
|
|
148
|
+
});
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
window.addEventListener(
|
|
153
|
+
'unhandledrejection',
|
|
154
|
+
safe(function (e) {
|
|
155
|
+
var reason = e.reason;
|
|
156
|
+
push(state.recentErrors, {
|
|
157
|
+
ts: new Date().toISOString(),
|
|
158
|
+
message: 'Unhandled rejection: ' + String((reason && reason.message) || reason),
|
|
159
|
+
stack: reason && reason.stack ? String(reason.stack).slice(0, 600) : null,
|
|
160
|
+
});
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// ---- styles -------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
var CSS = [
|
|
167
|
+
'.it-fab{position:fixed;right:18px;bottom:18px;width:52px;height:52px;border-radius:50%;',
|
|
168
|
+
'background:#1f2937;color:#fff;border:none;cursor:pointer;z-index:2147483000;font-size:22px;',
|
|
169
|
+
'box-shadow:0 4px 14px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;}',
|
|
170
|
+
'.it-fab:hover{background:#111827;transform:scale(1.06);}',
|
|
171
|
+
'.it-backdrop{position:fixed;inset:0;background:rgba(15,23,42,.45);z-index:2147483001;}',
|
|
172
|
+
'.it-dialog{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:min(440px,92vw);',
|
|
173
|
+
'max-height:88vh;overflow:auto;background:#fff;color:#111827;border-radius:12px;z-index:2147483002;',
|
|
174
|
+
'box-shadow:0 20px 60px rgba(0,0,0,.35);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;}',
|
|
175
|
+
'.it-dialog *{box-sizing:border-box;font-family:inherit;}',
|
|
176
|
+
'.it-head{display:flex;justify-content:space-between;align-items:center;padding:14px 16px;border-bottom:1px solid #e5e7eb;cursor:move;}',
|
|
177
|
+
'.it-head b{font-size:15px;}',
|
|
178
|
+
'.it-body{padding:14px 16px;display:flex;flex-direction:column;gap:10px;}',
|
|
179
|
+
'.it-row{display:flex;flex-direction:column;gap:4px;}',
|
|
180
|
+
'.it-label{font-size:12px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:.04em;}',
|
|
181
|
+
'.it-pills{display:flex;gap:6px;flex-wrap:wrap;}',
|
|
182
|
+
'.it-type-pill{border:1px solid #d1d5db;background:#fff;border-radius:999px;padding:4px 12px;cursor:pointer;font-size:13px;color:#374151;}',
|
|
183
|
+
'.it-type-pill.it-on{background:#1f2937;color:#fff;border-color:#1f2937;}',
|
|
184
|
+
'.it-input,.it-ta{width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;color:#111827;background:#fff;}',
|
|
185
|
+
'.it-ta{resize:vertical;}',
|
|
186
|
+
'.it-input:focus,.it-ta:focus{outline:2px solid #6366f1;outline-offset:-1px;}',
|
|
187
|
+
'.it-sevs{display:flex;gap:6px;align-items:center;}',
|
|
188
|
+
'.it-sev{width:26px;height:26px;border-radius:50%;border:1px solid #d1d5db;background:#fff;cursor:pointer;font-size:12px;color:#374151;}',
|
|
189
|
+
'.it-sev.it-on{background:#dc2626;border-color:#dc2626;color:#fff;}',
|
|
190
|
+
'.it-selhead{display:flex;justify-content:space-between;align-items:center;}',
|
|
191
|
+
'.it-selremove{background:none;border:none;color:#6b7280;font-size:12px;cursor:pointer;text-decoration:underline;padding:0;}',
|
|
192
|
+
'.it-selremove:hover{color:#dc2626;}',
|
|
193
|
+
'.it-selection{background:#f3f4f6;border:1px solid #e5e7eb;border-left:3px solid #6366f1;border-radius:6px;',
|
|
194
|
+
'padding:6px 8px;font-size:12.5px;color:#374151;max-height:76px;overflow-y:auto;white-space:pre-wrap;overflow-wrap:anywhere;}',
|
|
195
|
+
'.it-check{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151;cursor:pointer;}',
|
|
196
|
+
'.it-check input{accent-color:#6366f1;margin:0;}',
|
|
197
|
+
'.it-error{color:#dc2626;font-size:13px;min-height:16px;}',
|
|
198
|
+
'.it-actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid #e5e7eb;}',
|
|
199
|
+
'.it-btn{border-radius:8px;padding:8px 14px;font-size:14px;cursor:pointer;border:1px solid #d1d5db;background:#fff;color:#374151;}',
|
|
200
|
+
'.it-submit{background:#1f2937;border-color:#1f2937;color:#fff;font-weight:600;}',
|
|
201
|
+
'.it-submit:disabled{opacity:.6;cursor:wait;}',
|
|
202
|
+
'.it-toast{position:fixed;right:18px;bottom:80px;background:#1f2937;color:#fff;padding:12px 16px;border-radius:10px;',
|
|
203
|
+
'z-index:2147483003;box-shadow:0 8px 24px rgba(0,0,0,.3);font:13px/1.4 -apple-system,sans-serif;max-width:320px;}',
|
|
204
|
+
'.it-toast a{color:#93c5fd;}',
|
|
205
|
+
].join('');
|
|
206
|
+
|
|
207
|
+
function injectStyles() {
|
|
208
|
+
if (document.querySelector('style[data-issue-tracker]')) return;
|
|
209
|
+
var style = document.createElement('style');
|
|
210
|
+
style.setAttribute('data-issue-tracker', '1');
|
|
211
|
+
style.textContent = CSS;
|
|
212
|
+
document.head.appendChild(style);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---- snapshot -----------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
function snapshot(opts) {
|
|
218
|
+
opts = opts || {};
|
|
219
|
+
var ctx = {
|
|
220
|
+
capturedAt: new Date().toISOString(),
|
|
221
|
+
url: location.href,
|
|
222
|
+
viewport: { w: window.innerWidth, h: window.innerHeight },
|
|
223
|
+
userAgent: navigator.userAgent,
|
|
224
|
+
selectedText: opts.selection != null ? opts.selection : state.selectedText || '',
|
|
225
|
+
};
|
|
226
|
+
// The activity trail is bug forensics; "what am I looking at" above is
|
|
227
|
+
// always attached, the trail only when wanted (dialog checkbox).
|
|
228
|
+
if (opts.trail !== false) {
|
|
229
|
+
ctx.clickBreadcrumbs = state.clickBreadcrumbs.slice();
|
|
230
|
+
ctx.fetchBreadcrumbs = state.fetchBreadcrumbs.slice();
|
|
231
|
+
ctx.recentErrors = state.recentErrors.slice();
|
|
232
|
+
ctx.recentFetchFailures = state.recentFetchFailures.slice();
|
|
233
|
+
}
|
|
234
|
+
if (state.contextProvider) {
|
|
235
|
+
try {
|
|
236
|
+
ctx.app = state.contextProvider() || null;
|
|
237
|
+
} catch (err) {
|
|
238
|
+
ctx.app = { error: 'context provider threw: ' + String((err && err.message) || err) };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return ctx;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- dialog -------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
var ui = { backdrop: null, dialog: null };
|
|
247
|
+
|
|
248
|
+
function el(tag, cls, text) {
|
|
249
|
+
var node = document.createElement(tag);
|
|
250
|
+
if (cls) node.className = cls;
|
|
251
|
+
if (text != null) node.textContent = text;
|
|
252
|
+
return node;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function closeDialog() {
|
|
256
|
+
if (ui.backdrop) ui.backdrop.remove();
|
|
257
|
+
if (ui.dialog) ui.dialog.remove();
|
|
258
|
+
ui.backdrop = ui.dialog = null;
|
|
259
|
+
dragState = null;
|
|
260
|
+
document.removeEventListener('keydown', onKeydown);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
var onKeydown = safe(function (e) {
|
|
264
|
+
if (e.key === 'Escape') closeDialog();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// All toast content is plain text or an explicit link — never HTML, so a
|
|
268
|
+
// compromised tracker response cannot inject markup into the host app.
|
|
269
|
+
function toast(parts, ms) {
|
|
270
|
+
var t = el('div', 'it-toast');
|
|
271
|
+
t.setAttribute('data-issue-tracker-ui', '1');
|
|
272
|
+
if (parts.prefix) t.appendChild(document.createTextNode(parts.prefix));
|
|
273
|
+
if (parts.link) {
|
|
274
|
+
var a = el('a', null, parts.link.text);
|
|
275
|
+
a.href = parts.link.href;
|
|
276
|
+
a.target = '_blank';
|
|
277
|
+
a.rel = 'noopener';
|
|
278
|
+
t.appendChild(a);
|
|
279
|
+
}
|
|
280
|
+
if (parts.suffix) t.appendChild(document.createTextNode(parts.suffix));
|
|
281
|
+
document.body.appendChild(t);
|
|
282
|
+
setTimeout(safe(function () { t.remove(); }), ms || 6000);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function openDialog() {
|
|
286
|
+
if (ui.dialog) return;
|
|
287
|
+
injectStyles();
|
|
288
|
+
state.selectedType = 'bug';
|
|
289
|
+
state.selectedSeverity = 3;
|
|
290
|
+
|
|
291
|
+
// Programmatic opens (IssueTracker.open) get no FAB mousedown; if a live
|
|
292
|
+
// selection exists right now, prefer it over the last mousedown snapshot.
|
|
293
|
+
var liveSel = window.getSelection ? String(window.getSelection()) : '';
|
|
294
|
+
if (liveSel) state.selectedText = liveSel.slice(0, 500);
|
|
295
|
+
var captured = { selection: state.selectedText || '' };
|
|
296
|
+
var trail = null;
|
|
297
|
+
var trailTouched = false;
|
|
298
|
+
|
|
299
|
+
ui.backdrop = el('div', 'it-backdrop');
|
|
300
|
+
ui.backdrop.setAttribute('data-issue-tracker-ui', '1');
|
|
301
|
+
ui.backdrop.addEventListener('click', safe(closeDialog));
|
|
302
|
+
document.body.appendChild(ui.backdrop);
|
|
303
|
+
|
|
304
|
+
var d = el('div', 'it-dialog');
|
|
305
|
+
d.setAttribute('data-issue-tracker-ui', '1');
|
|
306
|
+
d.setAttribute('role', 'dialog');
|
|
307
|
+
d.setAttribute('aria-label', 'Report an issue');
|
|
308
|
+
|
|
309
|
+
var head = el('div', 'it-head');
|
|
310
|
+
head.appendChild(el('b', null, 'Report an issue'));
|
|
311
|
+
var x = el('button', 'it-btn it-cancel', '×');
|
|
312
|
+
x.addEventListener('click', safe(closeDialog));
|
|
313
|
+
head.appendChild(x);
|
|
314
|
+
d.appendChild(head);
|
|
315
|
+
|
|
316
|
+
var body = el('div', 'it-body');
|
|
317
|
+
|
|
318
|
+
var typeRow = el('div', 'it-row');
|
|
319
|
+
typeRow.appendChild(el('span', 'it-label', 'Type'));
|
|
320
|
+
var pills = el('div', 'it-pills');
|
|
321
|
+
TYPES.forEach(function (t) {
|
|
322
|
+
var pill = el('button', 'it-type-pill' + (t === state.selectedType ? ' it-on' : ''), t);
|
|
323
|
+
pill.setAttribute('data-type', t);
|
|
324
|
+
pill.addEventListener(
|
|
325
|
+
'click',
|
|
326
|
+
safe(function () {
|
|
327
|
+
state.selectedType = t;
|
|
328
|
+
pills.querySelectorAll('.it-type-pill').forEach(function (p) {
|
|
329
|
+
p.classList.toggle('it-on', p.getAttribute('data-type') === t);
|
|
330
|
+
});
|
|
331
|
+
// Trail default follows type (bug = forensics wanted) until the
|
|
332
|
+
// reporter overrides the checkbox themselves.
|
|
333
|
+
if (trail && !trailTouched) trail.checked = t === 'bug';
|
|
334
|
+
})
|
|
335
|
+
);
|
|
336
|
+
pills.appendChild(pill);
|
|
337
|
+
});
|
|
338
|
+
typeRow.appendChild(pills);
|
|
339
|
+
body.appendChild(typeRow);
|
|
340
|
+
|
|
341
|
+
var titleRow = el('div', 'it-row');
|
|
342
|
+
titleRow.appendChild(el('span', 'it-label', 'Title (optional)'));
|
|
343
|
+
var title = el('input', 'it-input it-title');
|
|
344
|
+
title.placeholder = 'Short summary';
|
|
345
|
+
titleRow.appendChild(title);
|
|
346
|
+
body.appendChild(titleRow);
|
|
347
|
+
|
|
348
|
+
var seeingRow = el('div', 'it-row');
|
|
349
|
+
seeingRow.appendChild(el('span', 'it-label', 'What are you seeing? *'));
|
|
350
|
+
var seeing = el('textarea', 'it-ta it-seeing');
|
|
351
|
+
seeing.rows = 4;
|
|
352
|
+
seeing.placeholder = 'Describe the problem or current behavior';
|
|
353
|
+
seeingRow.appendChild(seeing);
|
|
354
|
+
body.appendChild(seeingRow);
|
|
355
|
+
|
|
356
|
+
var expRow = el('div', 'it-row');
|
|
357
|
+
expRow.appendChild(el('span', 'it-label', 'What do you expect? *'));
|
|
358
|
+
var expecting = el('textarea', 'it-ta it-expecting');
|
|
359
|
+
expecting.rows = 3;
|
|
360
|
+
expecting.placeholder = 'Describe the desired behavior / requirements';
|
|
361
|
+
expRow.appendChild(expecting);
|
|
362
|
+
body.appendChild(expRow);
|
|
363
|
+
|
|
364
|
+
var sevRow = el('div', 'it-row');
|
|
365
|
+
sevRow.appendChild(el('span', 'it-label', 'Severity'));
|
|
366
|
+
var sevs = el('div', 'it-sevs');
|
|
367
|
+
for (var s = 1; s <= 5; s++) {
|
|
368
|
+
(function (sev) {
|
|
369
|
+
var dot = el('button', 'it-sev' + (sev <= state.selectedSeverity ? ' it-on' : ''), String(sev));
|
|
370
|
+
dot.setAttribute('data-sev', String(sev));
|
|
371
|
+
dot.addEventListener(
|
|
372
|
+
'click',
|
|
373
|
+
safe(function () {
|
|
374
|
+
state.selectedSeverity = sev;
|
|
375
|
+
sevs.querySelectorAll('.it-sev').forEach(function (el2) {
|
|
376
|
+
el2.classList.toggle('it-on', Number(el2.getAttribute('data-sev')) <= sev);
|
|
377
|
+
});
|
|
378
|
+
})
|
|
379
|
+
);
|
|
380
|
+
sevs.appendChild(dot);
|
|
381
|
+
})(s);
|
|
382
|
+
}
|
|
383
|
+
sevRow.appendChild(sevs);
|
|
384
|
+
body.appendChild(sevRow);
|
|
385
|
+
|
|
386
|
+
var tagsRow = el('div', 'it-row');
|
|
387
|
+
tagsRow.appendChild(el('span', 'it-label', 'Tags (comma-separated)'));
|
|
388
|
+
var tags = el('input', 'it-input it-tags');
|
|
389
|
+
tags.placeholder = 'auth, ui, performance';
|
|
390
|
+
tagsRow.appendChild(tags);
|
|
391
|
+
body.appendChild(tagsRow);
|
|
392
|
+
|
|
393
|
+
if (captured.selection) {
|
|
394
|
+
var selRow = el('div', 'it-row');
|
|
395
|
+
var selHead = el('div', 'it-selhead');
|
|
396
|
+
selHead.appendChild(el('span', 'it-label', 'Highlighted text (attached)'));
|
|
397
|
+
var selRemove = el('button', 'it-selremove', 'Remove');
|
|
398
|
+
selRemove.setAttribute('type', 'button');
|
|
399
|
+
selHead.appendChild(selRemove);
|
|
400
|
+
selRow.appendChild(selHead);
|
|
401
|
+
selRow.appendChild(el('div', 'it-selection', captured.selection));
|
|
402
|
+
selRemove.addEventListener(
|
|
403
|
+
'click',
|
|
404
|
+
safe(function () {
|
|
405
|
+
captured.selection = '';
|
|
406
|
+
selRow.remove();
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
body.appendChild(selRow);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
var trailRow = el('label', 'it-check');
|
|
413
|
+
trail = document.createElement('input');
|
|
414
|
+
trail.type = 'checkbox';
|
|
415
|
+
trail.className = 'it-trail';
|
|
416
|
+
trail.checked = state.selectedType === 'bug';
|
|
417
|
+
trail.addEventListener('change', safe(function () { trailTouched = true; }));
|
|
418
|
+
trailRow.appendChild(trail);
|
|
419
|
+
trailRow.appendChild(el('span', null, 'Include activity trail (clicks, network, errors)'));
|
|
420
|
+
body.appendChild(trailRow);
|
|
421
|
+
|
|
422
|
+
var error = el('div', 'it-error', '');
|
|
423
|
+
body.appendChild(error);
|
|
424
|
+
d.appendChild(body);
|
|
425
|
+
|
|
426
|
+
var actions = el('div', 'it-actions');
|
|
427
|
+
var cancel = el('button', 'it-btn it-cancel', 'Cancel');
|
|
428
|
+
cancel.addEventListener('click', safe(closeDialog));
|
|
429
|
+
actions.appendChild(cancel);
|
|
430
|
+
var submit = el('button', 'it-btn it-submit', 'Submit issue');
|
|
431
|
+
submit.addEventListener('click', safe(function () { doSubmit({ title: title, seeing: seeing, expecting: expecting, tags: tags, error: error, submit: submit, trail: trail, captured: captured }); }));
|
|
432
|
+
actions.appendChild(submit);
|
|
433
|
+
d.appendChild(actions);
|
|
434
|
+
|
|
435
|
+
document.body.appendChild(d);
|
|
436
|
+
ui.dialog = d;
|
|
437
|
+
document.addEventListener('keydown', onKeydown);
|
|
438
|
+
makeDraggable(d, head);
|
|
439
|
+
try { seeing.focus(); } catch (_) {}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Single module-level pair of drag listeners — repeated dialog opens must not
|
|
443
|
+
// accumulate document-level handlers.
|
|
444
|
+
var dragState = null;
|
|
445
|
+
document.addEventListener(
|
|
446
|
+
'mousemove',
|
|
447
|
+
safe(function (e) {
|
|
448
|
+
if (!dragState) return;
|
|
449
|
+
var dialog = dragState.dialog;
|
|
450
|
+
dialog.style.left = e.clientX - dragState.dx + dialog.offsetWidth / 2 + 'px';
|
|
451
|
+
dialog.style.top = e.clientY - dragState.dy + dialog.offsetHeight / 2 + 'px';
|
|
452
|
+
})
|
|
453
|
+
);
|
|
454
|
+
document.addEventListener('mouseup', safe(function () { dragState = null; }));
|
|
455
|
+
|
|
456
|
+
function makeDraggable(dialog, handle) {
|
|
457
|
+
handle.addEventListener(
|
|
458
|
+
'mousedown',
|
|
459
|
+
safe(function (e) {
|
|
460
|
+
if (e.target.tagName === 'BUTTON') return;
|
|
461
|
+
var rect = dialog.getBoundingClientRect();
|
|
462
|
+
dragState = { dialog: dialog, dx: e.clientX - rect.left, dy: e.clientY - rect.top };
|
|
463
|
+
e.preventDefault();
|
|
464
|
+
})
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function doSubmit(f) {
|
|
469
|
+
var seeing = f.seeing.value.trim();
|
|
470
|
+
var expecting = f.expecting.value.trim();
|
|
471
|
+
if (!seeing || !expecting) {
|
|
472
|
+
f.error.textContent = 'Please fill in both "seeing" and "expecting".';
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
f.error.textContent = '';
|
|
476
|
+
f.submit.disabled = true;
|
|
477
|
+
|
|
478
|
+
var payload = {
|
|
479
|
+
project: project,
|
|
480
|
+
title: f.title.value.trim(),
|
|
481
|
+
type: state.selectedType,
|
|
482
|
+
severity: state.selectedSeverity,
|
|
483
|
+
tags: f.tags.value
|
|
484
|
+
.split(',')
|
|
485
|
+
.map(function (t) { return t.trim(); })
|
|
486
|
+
.filter(Boolean),
|
|
487
|
+
seeing: seeing,
|
|
488
|
+
expecting: expecting,
|
|
489
|
+
context: snapshot({ trail: f.trail ? f.trail.checked : true, selection: f.captured ? f.captured.selection : null }),
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
window
|
|
493
|
+
.fetch(endpoint + '/api/issues', {
|
|
494
|
+
method: 'POST',
|
|
495
|
+
headers: { 'Content-Type': 'application/json' },
|
|
496
|
+
body: JSON.stringify(payload),
|
|
497
|
+
})
|
|
498
|
+
.then(function (res) {
|
|
499
|
+
if (!res.ok) throw new Error('tracker responded ' + res.status);
|
|
500
|
+
return res.json();
|
|
501
|
+
})
|
|
502
|
+
.then(
|
|
503
|
+
safe(function (issue) {
|
|
504
|
+
state.selectedText = ''; // consumed by this report
|
|
505
|
+
closeDialog();
|
|
506
|
+
toast({
|
|
507
|
+
prefix: 'Issue ',
|
|
508
|
+
link: { href: endpoint + '/#' + encodeURIComponent(issue.id), text: String(issue.id) },
|
|
509
|
+
suffix: ' captured.',
|
|
510
|
+
});
|
|
511
|
+
})
|
|
512
|
+
)
|
|
513
|
+
.catch(
|
|
514
|
+
safe(function (err) {
|
|
515
|
+
f.submit.disabled = false;
|
|
516
|
+
f.error.textContent = 'Could not reach the tracker (' + String((err && err.message) || err) + '). Is it running?';
|
|
517
|
+
})
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---- mount --------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
var mount = safe(function () {
|
|
524
|
+
if (!document.body) {
|
|
525
|
+
document.addEventListener('DOMContentLoaded', mount);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
injectStyles();
|
|
529
|
+
var fab = el('button', 'it-fab', '🐞');
|
|
530
|
+
fab.setAttribute('data-issue-tracker-ui', '1');
|
|
531
|
+
fab.setAttribute('aria-label', 'Report an issue');
|
|
532
|
+
fab.title = 'Report an issue';
|
|
533
|
+
fab.addEventListener(
|
|
534
|
+
'mousedown',
|
|
535
|
+
safe(function () {
|
|
536
|
+
// Always assign (even when empty) so a previous capture's selection
|
|
537
|
+
// can never leak into a later, unrelated report.
|
|
538
|
+
var sel = window.getSelection ? String(window.getSelection()) : '';
|
|
539
|
+
state.selectedText = sel.slice(0, 500);
|
|
540
|
+
})
|
|
541
|
+
);
|
|
542
|
+
fab.addEventListener('click', safe(openDialog));
|
|
543
|
+
document.body.appendChild(fab);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
window.IssueTracker = {
|
|
547
|
+
configure: safe(function (opts) {
|
|
548
|
+
if (opts && typeof opts.context === 'function') state.contextProvider = opts.context;
|
|
549
|
+
}),
|
|
550
|
+
open: safe(openDialog),
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
mount();
|
|
554
|
+
})();
|
package/server.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Issue tracker server: tracker UI, /api/*, and the embeddable /widget.js.
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { handleApi, setCors } from './lib/api.mjs';
|
|
7
|
+
import { resolveDataDir } from './lib/data-dir.mjs';
|
|
8
|
+
|
|
9
|
+
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const PUBLIC_DIR = path.join(ROOT, 'public');
|
|
11
|
+
|
|
12
|
+
const CONTENT_TYPES = {
|
|
13
|
+
'.html': 'text/html; charset=utf-8',
|
|
14
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
15
|
+
'.css': 'text/css; charset=utf-8',
|
|
16
|
+
'.json': 'application/json',
|
|
17
|
+
'.webmanifest': 'application/manifest+json',
|
|
18
|
+
'.svg': 'image/svg+xml',
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.ico': 'image/x-icon',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function serveStatic(req, res) {
|
|
24
|
+
let pathname;
|
|
25
|
+
try {
|
|
26
|
+
pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
|
27
|
+
} catch {
|
|
28
|
+
res.writeHead(400);
|
|
29
|
+
res.end('bad request');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (pathname === '/') pathname = '/index.html';
|
|
33
|
+
const file = path.resolve(PUBLIC_DIR, '.' + pathname);
|
|
34
|
+
if (!file.startsWith(PUBLIC_DIR + path.sep)) {
|
|
35
|
+
res.writeHead(404);
|
|
36
|
+
res.end('not found');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
40
|
+
res.writeHead(404);
|
|
41
|
+
res.end('not found');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (pathname === '/widget.js') setCors(res);
|
|
45
|
+
res.writeHead(200, {
|
|
46
|
+
'Content-Type': CONTENT_TYPES[path.extname(file)] || 'application/octet-stream',
|
|
47
|
+
'Cache-Control': 'no-store',
|
|
48
|
+
});
|
|
49
|
+
res.end(fs.readFileSync(file));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function startServer({ port = 4400, dataDir }) {
|
|
53
|
+
const server = http.createServer(async (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
const handled = await handleApi(req, res, { dataDir });
|
|
56
|
+
if (!handled) serveStatic(req, res);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error('[issue-tracker] request error:', err);
|
|
59
|
+
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
60
|
+
res.end(JSON.stringify({ error: 'internal error' }));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
server.on('error', reject);
|
|
65
|
+
server.listen(port, () => resolve(server));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
70
|
+
const port = Number(process.env.PORT) || 4400;
|
|
71
|
+
const dataDir = resolveDataDir();
|
|
72
|
+
startServer({ port, dataDir }).then(() => {
|
|
73
|
+
console.log(`VibeOps Tracker running at http://localhost:${port} (data: ${dataDir})`);
|
|
74
|
+
});
|
|
75
|
+
}
|