querysub 0.498.0 → 0.500.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/bin/autofixer.js +6 -0
- package/package.json +4 -2
- package/src/diagnostics/logs/errorNotifications2/ErrorNotificationPage.tsx +37 -0
- package/src/diagnostics/logs/errorNotifications2/errorNotifications.ts +2 -0
- package/src/diagnostics/logs/errorTickets/TicketPage.tsx +518 -0
- package/src/diagnostics/logs/errorTickets/autoFixer.ts +634 -0
- package/src/diagnostics/logs/errorTickets/autoFixerController.ts +41 -0
- package/src/diagnostics/logs/errorTickets/autoFixerEntry.ts +24 -0
- package/src/diagnostics/logs/errorTickets/ticketTypes.ts +47 -0
- package/src/diagnostics/logs/errorTickets/tickets.ts +264 -0
- package/src/diagnostics/managementPages.tsx +6 -0
package/bin/autofixer.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.500.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"mc": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts --cwd D:/repos/qs-cyoa/",
|
|
25
25
|
"mcp2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts",
|
|
26
26
|
"mc2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts --cwd D:/repos/qs-cyoa/",
|
|
27
|
+
"autofixer": "yarn typenode ./src/diagnostics/logs/errorTickets/autoFixerEntry.ts",
|
|
27
28
|
"ssh-a-claude": "ssh root@a.querysubtest.com"
|
|
28
29
|
},
|
|
29
30
|
"bin": {
|
|
@@ -47,7 +48,8 @@
|
|
|
47
48
|
"error-watch-public": "./bin/error-watch-public.js",
|
|
48
49
|
"audit-imports": "./bin/audit-imports.js",
|
|
49
50
|
"audit-disk-values": "./bin/audit-disk-values.js",
|
|
50
|
-
"mcp-indexed-logs": "./bin/mcp-indexed-logs.js"
|
|
51
|
+
"mcp-indexed-logs": "./bin/mcp-indexed-logs.js",
|
|
52
|
+
"autofixer": "./bin/autofixer.js"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|
|
53
55
|
"@types/fs-ext": "^2.0.3",
|
|
@@ -18,6 +18,18 @@ import { startTimeParam, endTimeParam } from "../TimeRangeSelector";
|
|
|
18
18
|
import { timeInHour } from "socket-function/src/misc";
|
|
19
19
|
import { ATag, URLOverride } from "../../../library-components/ATag";
|
|
20
20
|
import { Button } from "../../../library-components/Button";
|
|
21
|
+
import { createTicketForError, ticketIdURL } from "../errorTickets/TicketPage";
|
|
22
|
+
|
|
23
|
+
function openTicket(datum: LogDatum, pattern: string | undefined) {
|
|
24
|
+
Querysub.onCommitFinished(async () => {
|
|
25
|
+
let id = await createTicketForError(datum, pattern);
|
|
26
|
+
Querysub.commit(() => {
|
|
27
|
+
showingManagementURL.value = true;
|
|
28
|
+
managementPageURL.value = "TicketPage";
|
|
29
|
+
ticketIdURL.value = id;
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
21
33
|
|
|
22
34
|
let getMatcher = cacheLimited(100, (pattern: string) =>
|
|
23
35
|
createMatchesPattern(Buffer.from(pattern), false)
|
|
@@ -154,6 +166,18 @@ export class LogDatumRenderer extends qreact.Component<{
|
|
|
154
166
|
>
|
|
155
167
|
Not a Bug
|
|
156
168
|
</Button>
|
|
169
|
+
<Button
|
|
170
|
+
hue={210}
|
|
171
|
+
onClick={(e) => {
|
|
172
|
+
if (this.props.inlineMode) {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
e.stopPropagation();
|
|
175
|
+
}
|
|
176
|
+
openTicket(datum, timedOutMatches[0].pattern);
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
Ticket
|
|
180
|
+
</Button>
|
|
157
181
|
</div>
|
|
158
182
|
)}
|
|
159
183
|
|
|
@@ -412,6 +436,19 @@ class SuppressionItem extends qreact.Component<{
|
|
|
412
436
|
>
|
|
413
437
|
Not a Bug
|
|
414
438
|
</Button>
|
|
439
|
+
<Button
|
|
440
|
+
hue={210}
|
|
441
|
+
onClick={() => {
|
|
442
|
+
let datum = this.props.matchData?.examples[0] || {
|
|
443
|
+
time: Date.now(),
|
|
444
|
+
__LOG_TYPE: "error",
|
|
445
|
+
param0: suppression.pattern,
|
|
446
|
+
};
|
|
447
|
+
openTicket(datum, suppression.pattern);
|
|
448
|
+
}}
|
|
449
|
+
>
|
|
450
|
+
Ticket
|
|
451
|
+
</Button>
|
|
415
452
|
<Button
|
|
416
453
|
hue={30}
|
|
417
454
|
onClick={() => {
|
|
@@ -22,6 +22,7 @@ import { createLink } from "../../../library-components/ATag";
|
|
|
22
22
|
import { showingManagementURL, managementPageURL } from "../../managementPages";
|
|
23
23
|
import { atomic } from "../../../2-proxy/PathValueProxyWatcher";
|
|
24
24
|
import { magenta } from "socket-function/src/formatting/logColors";
|
|
25
|
+
import { exposeTicketService } from "../errorTickets/tickets";
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
export type SuppressionEntry = {
|
|
@@ -306,6 +307,7 @@ const trySendDiscordNotificationsBase = batchFunction({ delay: 1000 * 3 }, async
|
|
|
306
307
|
|
|
307
308
|
export async function exposeErrorWatchService() {
|
|
308
309
|
SocketFunction.expose(ErrorNotificationServiceBase);
|
|
310
|
+
exposeTicketService();
|
|
309
311
|
|
|
310
312
|
let errorLogs = await getErrorLogs();
|
|
311
313
|
for await (let error of watchAllValues(errorLogs)) {
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { qreact } from "../../../4-dom/qreact";
|
|
2
|
+
import { t } from "../../../2-proxy/schema2";
|
|
3
|
+
import { css } from "typesafecss";
|
|
4
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
5
|
+
import { lazy } from "socket-function/src/caching";
|
|
6
|
+
import { nextId, sort } from "socket-function/src/misc";
|
|
7
|
+
import { formatDateTime, formatTime } from "socket-function/src/formatting/format";
|
|
8
|
+
import { Querysub } from "../../../4-querysub/Querysub";
|
|
9
|
+
import { URLParam } from "../../../library-components/URLParam";
|
|
10
|
+
import { ATag } from "../../../library-components/ATag";
|
|
11
|
+
import { Button } from "../../../library-components/Button";
|
|
12
|
+
import { LogDatum } from "../diskLogger";
|
|
13
|
+
import { TicketsController } from "./tickets";
|
|
14
|
+
import { Ticket, TicketComment, TicketPatchFile, TicketState, TICKET_STATES } from "./ticketTypes";
|
|
15
|
+
|
|
16
|
+
export const ticketIdURL = new URLParam("ticketid", "");
|
|
17
|
+
|
|
18
|
+
const TITLE_MAX_LENGTH = 200;
|
|
19
|
+
|
|
20
|
+
const STATE_HUES: Record<TicketState, number> = {
|
|
21
|
+
"investigation": 45,
|
|
22
|
+
"code-change": 210,
|
|
23
|
+
"fixed": 120,
|
|
24
|
+
"not-a-bug": 280,
|
|
25
|
+
"timed-out": 0,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function getController() {
|
|
29
|
+
return TicketsController(SocketFunction.browserNodeId());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resetTicketData() {
|
|
33
|
+
let controller = getController();
|
|
34
|
+
controller.getTickets.resetAll();
|
|
35
|
+
controller.getTicket.resetAll();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function createTicketForError(datum: LogDatum, suppressionPattern?: string): Promise<string> {
|
|
39
|
+
let now = Date.now();
|
|
40
|
+
let title = (datum.param0 && String(datum.param0) || "(no message)").slice(0, TITLE_MAX_LENGTH);
|
|
41
|
+
let ticket: Ticket = {
|
|
42
|
+
id: nextId(),
|
|
43
|
+
title,
|
|
44
|
+
state: "investigation",
|
|
45
|
+
createdTime: now,
|
|
46
|
+
lastUpdatedTime: now,
|
|
47
|
+
errorDatum: datum,
|
|
48
|
+
suppressionPattern,
|
|
49
|
+
comments: [],
|
|
50
|
+
};
|
|
51
|
+
await getController().createTicket.promise(ticket);
|
|
52
|
+
resetTicketData();
|
|
53
|
+
return ticket.id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const HLJS_VERSION = "11.9.0";
|
|
57
|
+
let ensureHljsLoaded = lazy(async (): Promise<void> => {
|
|
58
|
+
if ((window as any).hljs) return;
|
|
59
|
+
let link = document.createElement("link");
|
|
60
|
+
link.rel = "stylesheet";
|
|
61
|
+
link.href = `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${HLJS_VERSION}/styles/github-dark.min.css`;
|
|
62
|
+
document.head.appendChild(link);
|
|
63
|
+
await new Promise<void>((resolve, reject) => {
|
|
64
|
+
let script = document.createElement("script");
|
|
65
|
+
script.src = `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/${HLJS_VERSION}/highlight.min.js`;
|
|
66
|
+
script.onload = () => resolve();
|
|
67
|
+
script.onerror = () => reject(new Error("Failed to load highlight.js"));
|
|
68
|
+
document.head.appendChild(script);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
class HighlightedCode extends qreact.Component<{
|
|
73
|
+
code: string;
|
|
74
|
+
file?: string;
|
|
75
|
+
}> {
|
|
76
|
+
state = t.state({
|
|
77
|
+
html: t.atomic<string>(""),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
componentDidMount() {
|
|
81
|
+
void (async () => {
|
|
82
|
+
try {
|
|
83
|
+
await ensureHljsLoaded();
|
|
84
|
+
let hljs = (window as any).hljs;
|
|
85
|
+
let extension = this.props.file?.split(".").pop() || "";
|
|
86
|
+
let html: string;
|
|
87
|
+
if (extension && hljs.getLanguage(extension)) {
|
|
88
|
+
html = hljs.highlight(this.props.code, { language: extension }).value;
|
|
89
|
+
} else {
|
|
90
|
+
html = hljs.highlightAuto(this.props.code).value;
|
|
91
|
+
}
|
|
92
|
+
Querysub.commit(() => {
|
|
93
|
+
this.state.html = html;
|
|
94
|
+
});
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error(`Failed to highlight code:`, (e as Error).stack ?? e);
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
render() {
|
|
102
|
+
let html = this.state.html;
|
|
103
|
+
return <pre className={
|
|
104
|
+
css.pad2(8).hsl(220, 15, 15).colorhsl(0, 0, 90).overflowAuto.maxHeight(400)
|
|
105
|
+
.fontSize(12).whiteSpace("pre-wrap").margin(0).fillWidth
|
|
106
|
+
+ " hljs"
|
|
107
|
+
}>
|
|
108
|
+
<code
|
|
109
|
+
key={html && "highlighted" || "plain"}
|
|
110
|
+
ref2={(element: HTMLElement | undefined) => {
|
|
111
|
+
if (!element) return;
|
|
112
|
+
if (html) {
|
|
113
|
+
element.innerHTML = html;
|
|
114
|
+
}
|
|
115
|
+
}}
|
|
116
|
+
>{!html && this.props.code || undefined}</code>
|
|
117
|
+
</pre>;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function StateBadge(props: { state: TicketState }) {
|
|
122
|
+
let hue = STATE_HUES[props.state] ?? 0;
|
|
123
|
+
return <span className={
|
|
124
|
+
css.pad2(6, 2).hsl(hue, 60, 85).colorhsl(hue, 80, 25).bord2(hue, 60, 60)
|
|
125
|
+
.fontSize(12).boldStyle.whiteSpace("nowrap")
|
|
126
|
+
}>
|
|
127
|
+
{props.state}
|
|
128
|
+
</span>;
|
|
129
|
+
}
|
|
130
|
+
qreact.inline(StateBadge);
|
|
131
|
+
|
|
132
|
+
export class TicketPage extends qreact.Component {
|
|
133
|
+
render() {
|
|
134
|
+
if (ticketIdURL.value) {
|
|
135
|
+
return <TicketDetail ticketId={ticketIdURL.value} />;
|
|
136
|
+
}
|
|
137
|
+
return <TicketList />;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
class TicketList extends qreact.Component {
|
|
142
|
+
render() {
|
|
143
|
+
let tickets = getController().getTickets();
|
|
144
|
+
if (!tickets) {
|
|
145
|
+
return <div className={css.vbox(16).pad2(16)}>
|
|
146
|
+
<h2>Tickets</h2>
|
|
147
|
+
<div>Loading tickets...</div>
|
|
148
|
+
</div>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let sorted = [...tickets];
|
|
152
|
+
sort(sorted, x => -x.lastUpdatedTime);
|
|
153
|
+
|
|
154
|
+
return <div className={css.vbox(16).pad2(16).fillWidth}>
|
|
155
|
+
<div className={css.hbox(16)}>
|
|
156
|
+
<h2>Tickets ({sorted.length})</h2>
|
|
157
|
+
<Button
|
|
158
|
+
hue={200}
|
|
159
|
+
onClick={() => {
|
|
160
|
+
resetTicketData();
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
Refresh
|
|
164
|
+
</Button>
|
|
165
|
+
</div>
|
|
166
|
+
{sorted.length === 0 && <div>No tickets. Create one from the Error Notifications page with the "Ticket" button.</div>}
|
|
167
|
+
<div className={css.vbox(8)}>
|
|
168
|
+
{sorted.map(ticket => (
|
|
169
|
+
<ATag
|
|
170
|
+
key={ticket.id}
|
|
171
|
+
values={[ticketIdURL.getOverride(ticket.id)]}
|
|
172
|
+
className={css.hbox(16).pad2(12).bord2(200, 30, 70).hsl(0, 0, 96).alignItems("center").textDecoration("none")}
|
|
173
|
+
>
|
|
174
|
+
<StateBadge state={ticket.state} />
|
|
175
|
+
<span className={css.ellipsis.flexFillWidth.colorhsl(0, 0, 10).boldStyle}>{ticket.title}</span>
|
|
176
|
+
<span className={css.colorhsl(0, 0, 40).whiteSpace("nowrap").fontSize(12)}>
|
|
177
|
+
{ticket.comments.length} comments
|
|
178
|
+
</span>
|
|
179
|
+
<span className={css.colorhsl(0, 0, 40).whiteSpace("nowrap").fontSize(12)}>
|
|
180
|
+
Created {formatDateTime(ticket.createdTime)}
|
|
181
|
+
</span>
|
|
182
|
+
<span className={css.colorhsl(0, 0, 40).whiteSpace("nowrap").fontSize(12)}>
|
|
183
|
+
Updated {formatTime(Date.now() - ticket.lastUpdatedTime)} ago
|
|
184
|
+
</span>
|
|
185
|
+
</ATag>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
</div>;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
class TicketDetail extends qreact.Component<{ ticketId: string }> {
|
|
193
|
+
state = t.state({
|
|
194
|
+
newCommentText: t.atomic<string>(""),
|
|
195
|
+
showingErrorInfo: t.atomic<boolean>(false),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
render() {
|
|
199
|
+
let ticket = getController().getTicket(this.props.ticketId);
|
|
200
|
+
if (!ticket) {
|
|
201
|
+
return <div className={css.vbox(16).pad2(16)}>
|
|
202
|
+
<ATag values={[ticketIdURL.getOverride("")]}>← All Tickets</ATag>
|
|
203
|
+
<div>Loading ticket...</div>
|
|
204
|
+
</div>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let sortedComments = [...ticket.comments];
|
|
208
|
+
sort(sortedComments, x => x.time);
|
|
209
|
+
|
|
210
|
+
return <div className={css.vbox(16).pad2(16).fillWidth.maxWidth("100%")}>
|
|
211
|
+
<div className={css.hbox(16).alignItems("center")}>
|
|
212
|
+
<ATag values={[ticketIdURL.getOverride("")]}>← All Tickets</ATag>
|
|
213
|
+
<StateBadge state={ticket.state} />
|
|
214
|
+
<h2 className={css.margin(0).ellipsis}>{ticket.title}</h2>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div className={css.hbox(8).alignItems("center").wrap}>
|
|
218
|
+
<span>Set state:</span>
|
|
219
|
+
{TICKET_STATES.map(state => (
|
|
220
|
+
<Button
|
|
221
|
+
key={state}
|
|
222
|
+
hue={STATE_HUES[state]}
|
|
223
|
+
disabled={state === ticket!.state}
|
|
224
|
+
onClick={() => {
|
|
225
|
+
Querysub.onCommitFinished(async () => {
|
|
226
|
+
await getController().setTicketState.promise(this.props.ticketId, state);
|
|
227
|
+
resetTicketData();
|
|
228
|
+
});
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
{state}
|
|
232
|
+
</Button>
|
|
233
|
+
))}
|
|
234
|
+
<Button
|
|
235
|
+
hue={0}
|
|
236
|
+
onClick={() => {
|
|
237
|
+
if (!confirm(`Delete this ticket "${ticket!.title}"?`)) return;
|
|
238
|
+
Querysub.onCommitFinished(async () => {
|
|
239
|
+
await getController().deleteTicket.promise(this.props.ticketId);
|
|
240
|
+
resetTicketData();
|
|
241
|
+
Querysub.commit(() => {
|
|
242
|
+
ticketIdURL.value = "";
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
Delete Ticket
|
|
248
|
+
</Button>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<div className={css.vbox(8).pad2(12).bord2(0, 50, 70).hsl(0, 30, 96)}>
|
|
252
|
+
<div
|
|
253
|
+
className={css.hbox(8).button}
|
|
254
|
+
onClick={() => {
|
|
255
|
+
this.state.showingErrorInfo = !this.state.showingErrorInfo;
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<span>{this.state.showingErrorInfo && "▼" || "▶"}</span>
|
|
259
|
+
<strong>Error Information</strong>
|
|
260
|
+
<span className={css.colorhsl(0, 0, 40)}>{formatDateTime(ticket.errorDatum.time)}</span>
|
|
261
|
+
</div>
|
|
262
|
+
{ticket.suppressionPattern && (
|
|
263
|
+
<div className={css.hbox(8)}>
|
|
264
|
+
<strong>Suppression Pattern:</strong>
|
|
265
|
+
<span>{ticket.suppressionPattern}</span>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
{this.state.showingErrorInfo && (
|
|
269
|
+
<div className={css.vbox(4).pad2(8).hsl(0, 0, 98).bord2(0, 0, 85)}>
|
|
270
|
+
{Object.entries(ticket.errorDatum).map(([key, value]) => (
|
|
271
|
+
<div key={key} className={css.hbox(8)}>
|
|
272
|
+
<strong className={css.minWidth(120)}>{key}:</strong>
|
|
273
|
+
<div className={css.flexGrow(1).margin(0).whiteSpace("pre-wrap").overflowWrap("break-word")}>{JSON.stringify(value, undefined, 2)}</div>
|
|
274
|
+
</div>
|
|
275
|
+
))}
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
<div className={css.vbox(12)}>
|
|
281
|
+
<h3>Comments ({sortedComments.length})</h3>
|
|
282
|
+
{sortedComments.map(comment => (
|
|
283
|
+
<TicketCommentItem key={comment.id} ticketId={this.props.ticketId} comment={comment} />
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<div className={css.vbox(8).pad2(12).bord2(120, 50, 60).hsl(120, 50, 96)}>
|
|
288
|
+
<strong>Add Comment</strong>
|
|
289
|
+
<textarea
|
|
290
|
+
className={css.minHeight(80).fillWidth.pad2(8)}
|
|
291
|
+
value={this.state.newCommentText}
|
|
292
|
+
onInput={e => {
|
|
293
|
+
this.state.newCommentText = (e.target as HTMLTextAreaElement).value;
|
|
294
|
+
}}
|
|
295
|
+
/>
|
|
296
|
+
<div className={css.hbox(8)}>
|
|
297
|
+
<Button
|
|
298
|
+
hue={120}
|
|
299
|
+
onClick={() => {
|
|
300
|
+
let text = this.state.newCommentText.trim();
|
|
301
|
+
if (!text) return;
|
|
302
|
+
Querysub.onCommitFinished(async () => {
|
|
303
|
+
let comment: TicketComment = {
|
|
304
|
+
id: nextId(),
|
|
305
|
+
time: Date.now(),
|
|
306
|
+
author: "user",
|
|
307
|
+
kind: "text",
|
|
308
|
+
text,
|
|
309
|
+
};
|
|
310
|
+
await getController().addComment.promise(this.props.ticketId, comment);
|
|
311
|
+
resetTicketData();
|
|
312
|
+
Querysub.commit(() => {
|
|
313
|
+
this.state.newCommentText = "";
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}}
|
|
317
|
+
>
|
|
318
|
+
Add Comment
|
|
319
|
+
</Button>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
</div>;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
class TicketCommentItem extends qreact.Component<{
|
|
327
|
+
ticketId: string;
|
|
328
|
+
comment: TicketComment;
|
|
329
|
+
}> {
|
|
330
|
+
state = t.state({
|
|
331
|
+
editing: t.atomic<boolean>(false),
|
|
332
|
+
editText: t.atomic<string>(""),
|
|
333
|
+
expandedTool: t.atomic<boolean>(false),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
render() {
|
|
337
|
+
let comment = this.props.comment;
|
|
338
|
+
|
|
339
|
+
let hue = comment.author === "user" && 120 || 210;
|
|
340
|
+
if (comment.kind === "tool-call") {
|
|
341
|
+
hue = 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return <div className={css.vbox(8).pad2(12).bord2(hue, 30, 70).hsl(hue, 20, 97)}>
|
|
345
|
+
<div className={css.hbox(12).alignItems("center")}>
|
|
346
|
+
<strong>{comment.author}</strong>
|
|
347
|
+
<span className={css.colorhsl(0, 0, 40).fontSize(12)}>{formatDateTime(comment.time)}</span>
|
|
348
|
+
{comment.kind === "patch" && (
|
|
349
|
+
<span className={css.pad2(6, 2).hsl(210, 60, 85).colorhsl(210, 80, 25).fontSize(12).boldStyle}>PATCH</span>
|
|
350
|
+
)}
|
|
351
|
+
{comment.kind === "tool-call" && (
|
|
352
|
+
<span className={css.pad2(6, 2).hsl(0, 0, 85).colorhsl(0, 0, 25).fontSize(12).boldStyle}>TOOL</span>
|
|
353
|
+
)}
|
|
354
|
+
<div className={css.flexFillWidth} />
|
|
355
|
+
{comment.kind === "text" && !this.state.editing && (
|
|
356
|
+
<Button
|
|
357
|
+
hue={200}
|
|
358
|
+
flavor="small"
|
|
359
|
+
onClick={() => {
|
|
360
|
+
this.state.editing = true;
|
|
361
|
+
this.state.editText = comment.text;
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
Edit
|
|
365
|
+
</Button>
|
|
366
|
+
)}
|
|
367
|
+
<Button
|
|
368
|
+
hue={0}
|
|
369
|
+
flavor="small"
|
|
370
|
+
onClick={() => {
|
|
371
|
+
if (!confirm(`Delete this comment?`)) return;
|
|
372
|
+
Querysub.onCommitFinished(async () => {
|
|
373
|
+
await getController().deleteComment.promise(this.props.ticketId, comment.id);
|
|
374
|
+
resetTicketData();
|
|
375
|
+
});
|
|
376
|
+
}}
|
|
377
|
+
>
|
|
378
|
+
Delete
|
|
379
|
+
</Button>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
{comment.kind === "tool-call" && (
|
|
383
|
+
<div className={css.vbox(8)}>
|
|
384
|
+
<div
|
|
385
|
+
className={css.hbox(8).button}
|
|
386
|
+
onClick={() => {
|
|
387
|
+
this.state.expandedTool = !this.state.expandedTool;
|
|
388
|
+
}}
|
|
389
|
+
>
|
|
390
|
+
<span>{this.state.expandedTool && "▼" || "▶"}</span>
|
|
391
|
+
<strong>{comment.toolName}</strong>
|
|
392
|
+
{comment.toolDurationMs !== undefined && (
|
|
393
|
+
<span className={css.colorhsl(0, 0, 40).fontSize(12)}>{formatTime(comment.toolDurationMs)}</span>
|
|
394
|
+
)}
|
|
395
|
+
<span className={css.ellipsis.colorhsl(0, 0, 40).fontSize(12)}>{comment.toolInput}</span>
|
|
396
|
+
</div>
|
|
397
|
+
{this.state.expandedTool && (
|
|
398
|
+
<div className={css.vbox(8)}>
|
|
399
|
+
<strong>Input</strong>
|
|
400
|
+
<pre className={css.pad2(8).hsl(0, 0, 92).whiteSpace("pre-wrap").overflowWrap("break-word").margin(0).fontSize(12).maxHeight(200).overflowAuto}>{comment.toolInput}</pre>
|
|
401
|
+
<strong>Output</strong>
|
|
402
|
+
<pre className={css.pad2(8).hsl(0, 0, 92).whiteSpace("pre-wrap").overflowWrap("break-word").margin(0).fontSize(12).maxHeight(400).overflowAuto}>{comment.toolOutput}</pre>
|
|
403
|
+
</div>
|
|
404
|
+
)}
|
|
405
|
+
</div>
|
|
406
|
+
)}
|
|
407
|
+
|
|
408
|
+
{comment.kind !== "tool-call" && !this.state.editing && (
|
|
409
|
+
<div className={css.whiteSpace("pre-wrap")}>{comment.text}</div>
|
|
410
|
+
)}
|
|
411
|
+
|
|
412
|
+
{this.state.editing && (
|
|
413
|
+
<div className={css.vbox(8)}>
|
|
414
|
+
<textarea
|
|
415
|
+
className={css.minHeight(80).fillWidth.pad2(8)}
|
|
416
|
+
value={this.state.editText}
|
|
417
|
+
onInput={e => {
|
|
418
|
+
this.state.editText = (e.target as HTMLTextAreaElement).value;
|
|
419
|
+
}}
|
|
420
|
+
/>
|
|
421
|
+
<div className={css.hbox(8)}>
|
|
422
|
+
<Button
|
|
423
|
+
hue={120}
|
|
424
|
+
onClick={() => {
|
|
425
|
+
let text = this.state.editText.trim();
|
|
426
|
+
Querysub.onCommitFinished(async () => {
|
|
427
|
+
await getController().updateCommentText.promise(this.props.ticketId, comment.id, text);
|
|
428
|
+
resetTicketData();
|
|
429
|
+
Querysub.commit(() => {
|
|
430
|
+
this.state.editing = false;
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
}}
|
|
434
|
+
>
|
|
435
|
+
Save
|
|
436
|
+
</Button>
|
|
437
|
+
<Button
|
|
438
|
+
onClick={() => {
|
|
439
|
+
this.state.editing = false;
|
|
440
|
+
}}
|
|
441
|
+
>
|
|
442
|
+
Cancel
|
|
443
|
+
</Button>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
)}
|
|
447
|
+
|
|
448
|
+
{comment.kind === "patch" && comment.patchFiles && (
|
|
449
|
+
<div className={css.vbox(12)}>
|
|
450
|
+
{comment.patchFiles.map((patchFile, idx) => (
|
|
451
|
+
<PatchFileView key={idx} patchFile={patchFile} />
|
|
452
|
+
))}
|
|
453
|
+
<div className={css.hbox(12).alignItems("center")}>
|
|
454
|
+
{comment.patchStatus === "pending" && (
|
|
455
|
+
<>
|
|
456
|
+
<Button
|
|
457
|
+
hue={120}
|
|
458
|
+
onClick={() => {
|
|
459
|
+
Querysub.onCommitFinished(async () => {
|
|
460
|
+
try {
|
|
461
|
+
await getController().applyPatch.promise(this.props.ticketId, comment.id);
|
|
462
|
+
} catch (e) {
|
|
463
|
+
alert(`Failed to apply patch: ${(e as Error).message}`);
|
|
464
|
+
}
|
|
465
|
+
resetTicketData();
|
|
466
|
+
});
|
|
467
|
+
}}
|
|
468
|
+
>
|
|
469
|
+
Apply Patch
|
|
470
|
+
</Button>
|
|
471
|
+
<Button
|
|
472
|
+
hue={0}
|
|
473
|
+
onClick={() => {
|
|
474
|
+
Querysub.onCommitFinished(async () => {
|
|
475
|
+
await getController().setPatchStatus.promise(this.props.ticketId, comment.id, "rejected");
|
|
476
|
+
resetTicketData();
|
|
477
|
+
});
|
|
478
|
+
}}
|
|
479
|
+
>
|
|
480
|
+
Reject Patch
|
|
481
|
+
</Button>
|
|
482
|
+
</>
|
|
483
|
+
)}
|
|
484
|
+
{comment.patchStatus === "applied" && (
|
|
485
|
+
<span className={css.colorhsl(120, 80, 25).boldStyle}>
|
|
486
|
+
Applied {comment.patchStatusTime && formatDateTime(comment.patchStatusTime) || ""}
|
|
487
|
+
</span>
|
|
488
|
+
)}
|
|
489
|
+
{comment.patchStatus === "rejected" && (
|
|
490
|
+
<span className={css.colorhsl(0, 80, 40).boldStyle}>
|
|
491
|
+
Rejected {comment.patchStatusTime && formatDateTime(comment.patchStatusTime) || ""}
|
|
492
|
+
</span>
|
|
493
|
+
)}
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
)}
|
|
497
|
+
</div>;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
class PatchFileView extends qreact.Component<{ patchFile: TicketPatchFile }> {
|
|
502
|
+
render() {
|
|
503
|
+
let patchFile = this.props.patchFile;
|
|
504
|
+
return <div className={css.vbox(4)}>
|
|
505
|
+
<strong className={css.fontSize(13)}>{patchFile.file}</strong>
|
|
506
|
+
{patchFile.oldText && (
|
|
507
|
+
<div className={css.vbox(2)}>
|
|
508
|
+
<span className={css.colorhsl(0, 70, 40).fontSize(12).boldStyle}>- Old</span>
|
|
509
|
+
<HighlightedCode code={patchFile.oldText} file={patchFile.file} />
|
|
510
|
+
</div>
|
|
511
|
+
)}
|
|
512
|
+
<div className={css.vbox(2)}>
|
|
513
|
+
<span className={css.colorhsl(120, 70, 30).fontSize(12).boldStyle}>+ New{!patchFile.oldText && " (file created)" || ""}</span>
|
|
514
|
+
<HighlightedCode code={patchFile.newText} file={patchFile.file} />
|
|
515
|
+
</div>
|
|
516
|
+
</div>;
|
|
517
|
+
}
|
|
518
|
+
}
|