querysub 0.378.0 → 0.379.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.
@@ -1,276 +1,505 @@
1
1
  import { qreact } from "../../../4-dom/qreact";
2
2
  import { t } from "../../../2-proxy/schema2";
3
3
  import { css } from "typesafecss";
4
- import { ErrorNotificationsController, watchUnmatchedErrors } from "./errorNotifications";
5
4
  import { LogDatum } from "../diskLogger";
6
- import { sort, nextId } from "socket-function/src/misc";
7
- import { formatVeryNiceDateTime } from "socket-function/src/formatting/format";
5
+ import { sort, timeInDay } from "socket-function/src/misc";
6
+ import { formatDateTime, formatNumber, formatTime } from "socket-function/src/formatting/format";
8
7
  import { Querysub } from "../../../4-querysub/QuerysubController";
9
8
  import { InputLabel } from "../../../library-components/InputLabel";
10
- import { SocketFunction } from "socket-function/SocketFunction";
11
9
  import { isPublic } from "../../../config";
10
+ import { MachineThreadInfo } from "../../MachineThreadInfo";
11
+ import { ErrorWarning } from "./ErrorWarning";
12
+ import { getErrorNotificationsManager } from "./errorWatcher";
13
+ import { createMatchesPattern } from "../IndexedLogs/bufferSearchFindMatcher";
14
+ import { cacheLimited } from "socket-function/src/caching";
15
+ import { managementPageURL } from "../../managementPages";
16
+ import { searchTextURL, readProductionLogsURL } from "../IndexedLogs/LogViewerParams";
17
+ import { startTimeParam, endTimeParam } from "../TimeRangeSelector";
18
+ import { timeInHour } from "socket-function/src/misc";
19
+ import { ATag, URLOverride } from "../../../library-components/ATag";
20
+ import { Button } from "../../../library-components/Button";
12
21
 
13
- export class ErrorNotificationPage extends qreact.Component {
22
+ let getMatcher = cacheLimited(100, (pattern: string) =>
23
+ createMatchesPattern(Buffer.from(pattern), false)
24
+ );
25
+
26
+ export class LogDatumRenderer extends qreact.Component<{
27
+ datum: LogDatum;
28
+ inlineMode?: boolean;
29
+ hideSuppression?: boolean;
30
+ pattern?: string;
31
+ }> {
14
32
  state = t.state({
15
- unmatchedErrors: t.atomic<LogDatum[]>([]),
16
- suppressionMatches: t.atomic<Map<string, {
17
- history: Map<number, { count: number }>;
18
- exampleIndex: number;
19
- examples: LogDatum[];
20
- }>>(new Map()),
21
- suppressionEntries: t.atomic<Array<{
22
- id: string;
23
- description: string;
24
- pattern: string;
25
- createdTime: number;
26
- lastUpdatedTime: number;
27
- }>>([]),
28
- addingNewSuppression: t.atomic<boolean>(false),
29
- newSuppressionDescription: t.atomic<string>(""),
30
- newSuppressionPattern: t.atomic<string>(""),
31
- initError: t.atomic<string | undefined>(undefined),
32
- isLoading: t.atomic<boolean>(true),
33
- testErrorMessage: t.atomic<string>("example error"),
33
+ expanded: t.atomic<boolean>(false),
34
34
  });
35
35
 
36
+ manager = getErrorNotificationsManager();
36
37
 
37
- unsubscribe?: () => void;
38
+ render() {
39
+ let datum = this.props.datum;
40
+ let mainMessage = datum.param0 && String(datum.param0) || "(no message)";
38
41
 
39
- componentDidMount() {
40
- Querysub.onCommitFinished(async () => {
41
- try {
42
- this.unsubscribe = await watchUnmatchedErrors({
43
- callback: (datums: LogDatum[]) => {
44
- Querysub.commit(() => {
45
- this.state.unmatchedErrors = [...this.state.unmatchedErrors, ...datums];
46
- });
47
- },
48
- });
49
-
50
- let controller = ErrorNotificationsController(SocketFunction.browserNodeId());
51
- let data = await controller.getData.promise();
52
- let suppressionEntries = await controller.getSuppressionEntries.promise();
53
-
54
- Querysub.commit(() => {
55
- this.state.unmatchedErrors = data.unmatchedErrors;
56
- this.state.suppressionMatches = data.suppressionMatches;
57
- this.state.suppressionEntries = suppressionEntries;
58
- this.state.initError = undefined;
59
- this.state.isLoading = false;
60
- });
61
- } catch (error) {
62
- Querysub.commit(() => {
63
- this.state.initError = error instanceof Error ? error.message : String(error);
64
- this.state.isLoading = false;
65
- });
42
+ let matchingSuppressions = [];
43
+ if (!this.manager.state.isLoading && !this.props.hideSuppression) {
44
+ for (let suppression of this.manager.state.suppressionEntries) {
45
+ let matcher = getMatcher(suppression.pattern);
46
+ let errorBuffer = Buffer.from(JSON.stringify(datum));
47
+ if (matcher(errorBuffer)) {
48
+ matchingSuppressions.push(suppression);
49
+ }
66
50
  }
67
- });
68
- }
51
+ }
52
+
53
+ let timedOutMatches = matchingSuppressions.filter(s => datum.time > s.timeout);
54
+ let shouldBeSuppressed = matchingSuppressions.filter(s => datum.time <= s.timeout);
69
55
 
70
- componentWillUnmount() {
71
- this.unsubscribe?.();
56
+ let logViewerValues: URLOverride[] | undefined;
57
+ {
58
+ logViewerValues = [
59
+ managementPageURL.getOverride("LogViewer3"),
60
+ readProductionLogsURL.getOverride(isPublic()),
61
+ startTimeParam.getOverride(datum.time - timeInHour),
62
+ endTimeParam.getOverride(datum.time + timeInHour),
63
+ ];
64
+
65
+ let searchText = this.props.pattern && this.props.pattern || timedOutMatches[0]?.pattern || "";
66
+ if (datum.__threadId) {
67
+ if (searchText) {
68
+ searchText += "&";
69
+ }
70
+ searchText += `"__threadId":"${datum.__threadId}"`;
71
+ }
72
+ if (searchText) {
73
+ logViewerValues.push(searchTextURL.getOverride(searchText));
74
+ }
75
+ }
76
+
77
+ return <div className={
78
+ css.vbox(4)
79
+ + (!this.props.inlineMode && css.maxWidth("80vw"))
80
+ + (this.props.inlineMode && css.maxWidth("calc(100vw - 90px)"))
81
+ }>
82
+ <div
83
+ className={
84
+ css.hbox(8).maxWidth("100%")
85
+ }
86
+ >
87
+ {logViewerValues && (
88
+ <ATag values={logViewerValues}>
89
+ View Logs
90
+ </ATag>
91
+ )}
92
+ <div
93
+ className={
94
+ css.hbox(8)
95
+ + (!this.props.inlineMode && css.button)
96
+ }
97
+ onClick={!this.props.inlineMode && (() => {
98
+ this.state.expanded = !this.state.expanded;
99
+ }) || undefined}
100
+ >
101
+ {!this.props.inlineMode && <span>{this.state.expanded && "▼" || "▶"}</span>}
102
+ <div>{formatDateTime(datum.time)}</div>
103
+ </div>
104
+ {datum.__machineId && <MachineThreadInfo machineId={datum.__machineId} threadId={datum.__threadId} />}
105
+ <span className={css.ellipsis.flexFillWidth.colorhsl(0, 50, 50).boldStyle}>{mainMessage}</span>
106
+ </div>
107
+
108
+ {!this.props.inlineMode && this.state.expanded && (
109
+ <div className={css.vbox(4).pad2(8).hsl(0, 0, 98).bord2(0, 0, 85)}>
110
+ {Object.entries(datum).map(([key, value]) => (
111
+ <div key={key} className={css.hbox(8)}>
112
+ <strong className={css.minWidth(120)}>{key}:</strong>
113
+ <pre className={css.flexGrow(1).margin(0)}>{JSON.stringify(value, undefined, 2)}</pre>
114
+ </div>
115
+ ))}
116
+ </div>
117
+ )}
118
+
119
+ {timedOutMatches.length > 0 && (
120
+ <div className={css.hbox(8).pad2(8).bord2(200, 50, 60).hsl(200, 50, 95)}>
121
+ <span>
122
+ <span className={css.colorhsl(0, 0, 50)}>(expired {formatTime(Date.now() - timedOutMatches[0].timeout)} AGO)</span>
123
+ {" "}
124
+ <span className={css.colorhsl(0, 0, 0).boldStyle}>{timedOutMatches[0].pattern}</span>
125
+ {timedOutMatches[0].notes && <span className={css.colorhsl(210, 80, 40)}> ({timedOutMatches[0].notes})</span>}
126
+ </span>
127
+ <Button
128
+ hue={30}
129
+ onClick={(e) => {
130
+ if (this.props.inlineMode) {
131
+ e.preventDefault();
132
+ e.stopPropagation();
133
+ }
134
+ let timeout = Date.now() + timeInDay * 2;
135
+ Querysub.onCommitFinished(async () => {
136
+ await this.manager.updateTimeout(timedOutMatches[0].id, timeout);
137
+ });
138
+ }}
139
+ >
140
+ Fixed
141
+ </Button>
142
+ <Button
143
+ hue={120}
144
+ onClick={(e) => {
145
+ if (this.props.inlineMode) {
146
+ e.preventDefault();
147
+ e.stopPropagation();
148
+ }
149
+ Querysub.onCommitFinished(async () => {
150
+ await this.manager.updateTimeout(timedOutMatches[0].id, Infinity);
151
+ });
152
+ }}
153
+ >
154
+ Not a Bug
155
+ </Button>
156
+ </div>
157
+ )}
158
+
159
+ {shouldBeSuppressed.length > 0 && timedOutMatches.length === 0 && (
160
+ <div className={css.pad2(8).bord2(30, 50, 60).hsl(30, 50, 95)}>
161
+ <span>
162
+ <span className={css.colorhsl(0, 0, 50)}>(for {formatTime(Date.now() - shouldBeSuppressed[0].lastUpdatedTime)})</span>
163
+ {" "}
164
+ <span className={css.colorhsl(0, 80, 30)}>{shouldBeSuppressed[0].pattern}</span>
165
+ {shouldBeSuppressed[0].notes && <span className={css.colorhsl(210, 80, 40)}> ({shouldBeSuppressed[0].notes})</span>}
166
+ </span>
167
+ </div>
168
+ )}
169
+ </div>;
72
170
  }
171
+ }
172
+
173
+ export class ErrorNotificationPage extends qreact.Component {
174
+ state = t.state({
175
+ newFixedPattern: t.atomic<string>(""),
176
+ newNotABugPattern: t.atomic<string>(""),
177
+ testErrorMessage: t.atomic<string>("example error"),
178
+ showingAllErrors: t.atomic<boolean>(false),
179
+ });
180
+
181
+ manager = getErrorNotificationsManager();
73
182
 
74
183
  render() {
75
- if (this.state.isLoading) {
184
+ if (this.manager.state.isLoading) {
76
185
  return <div className={css.vbox(16).pad2(16)}>
77
186
  <h2>Error Notifications</h2>
78
187
  <div>Loading error notifications...</div>
79
188
  </div>;
80
189
  }
81
190
 
82
- if (this.state.initError) {
191
+ if (this.manager.state.initError) {
83
192
  return <div className={css.vbox(16).pad2(16)}>
84
193
  <h2>Error Notifications</h2>
194
+ {!isPublic() && <h1>
195
+ IMPORTANT! You MUST run `yarn error-watch` to get errors notifications in the dev environment.
196
+ </h1>}
85
197
  <div className={css.pad2(12).bord2(0, 80, 50).hsl(0, 80, 95).colorhsl(0, 80, 30)}>
86
198
  <strong>Error loading error notifications:</strong>
87
199
  <pre className={css.pad2(8).hsl(0, 0, 98).bord2(0, 0, 85)}>
88
- {this.state.initError}
200
+ {this.manager.state.initError}
89
201
  </pre>
90
202
  </div>
91
203
  </div>;
92
204
  }
93
205
 
94
- let sortedErrors = [...this.state.unmatchedErrors];
95
- sort(sortedErrors, x => -x.time);
96
- let recentErrors = sortedErrors.slice(0, 10);
206
+ let sortedErrors = [...this.manager.state.unmatchedErrors];
207
+ sort(sortedErrors, x => x.time);
208
+ let displayedErrors = this.state.showingAllErrors && sortedErrors || sortedErrors.slice(0, 5);
97
209
 
98
- let sortedSuppressions = [...this.state.suppressionEntries];
210
+ let sortedSuppressions = [...this.manager.state.suppressionEntries];
99
211
  sort(sortedSuppressions, x => -x.lastUpdatedTime);
100
212
 
101
- return <div className={css.vbox(16).pad2(16)}>
213
+ return <div className={css.vbox(16).pad2(16).fillWidth}>
102
214
  <h2>Error Notifications</h2>
103
215
 
104
- {!isPublic() && <h1>
105
- IMPORTANT! You MUST run `yarn error-watch` to get errors notifications in the dev environment.
106
- </h1>}
107
-
108
216
  <div className={css.hbox(12).pad2(12).bord2(30, 50, 60).hsl(30, 50, 95)}>
109
217
  <InputLabel
110
218
  label="Test Error Message"
219
+ className={css.width(600)}
111
220
  value={this.state.testErrorMessage}
112
221
  onChangeValue={(value) => {
113
222
  this.state.testErrorMessage = value;
114
223
  }}
115
- className={css.width(400)}
116
224
  />
117
- <button
118
- className={css.pad2(12, 8).button.bord2(30, 80, 50).hsl(30, 80, 85)}
225
+ <Button
226
+ hue={30}
119
227
  onClick={() => {
120
228
  let message = this.state.testErrorMessage;
121
229
  Querysub.onCommitFinished(async () => {
122
- let controller = ErrorNotificationsController(SocketFunction.browserNodeId());
123
- await controller.logTestError.promise(message);
230
+ await this.manager.logTestError(message);
124
231
  });
125
232
  }}
126
233
  >
127
234
  Log Test Error
128
- </button>
235
+ </Button>
129
236
  </div>
130
237
 
131
238
  <div className={css.vbox(12)}>
132
- <h3>Unmatched Errors ({this.state.unmatchedErrors.length})</h3>
133
- {recentErrors.length === 0 && <div>No unmatched errors</div>}
134
- <div className={css.vbox(8)}>
135
- {recentErrors.map((error, idx) => (
136
- <div key={idx} className={css.pad2(12).bord2(0, 30, 70).hsl(0, 50, 95).vbox(4)}>
137
- <div className={css.hbox(8)}>
138
- <strong>{formatVeryNiceDateTime(error.time)}</strong>
139
- {error.machineId && <span>Machine: {error.machineId}</span>}
140
- </div>
141
- <pre className={css.overflowAuto.pad2(8).hsl(0, 0, 98).bord2(0, 0, 85)}>
142
- {JSON.stringify(error)}
143
- </pre>
144
- </div>
239
+ <h3>Unmatched Errors ({this.manager.state.unmatchedErrors.length})</h3>
240
+ {displayedErrors.length === 0 && <div>No unmatched errors</div>}
241
+ <div className={css.vbox(0)}>
242
+ {displayedErrors.map((error, idx) => (
243
+ <LogDatumRenderer key={idx} datum={error} />
145
244
  ))}
146
245
  </div>
147
- </div>
148
-
149
- <div className={css.vbox(12)}>
150
- <div className={css.hbox(12)}>
151
- <h3>Suppressions ({sortedSuppressions.length})</h3>
152
- <button
153
- className={css.pad2(12, 8).button.bord2(120, 80, 50).hsl(120, 80, 90)}
246
+ {sortedErrors.length > 5 && (
247
+ <Button
248
+ hue={200}
154
249
  onClick={() => {
155
- this.state.addingNewSuppression = !this.state.addingNewSuppression;
250
+ this.state.showingAllErrors = !this.state.showingAllErrors;
156
251
  }}
157
252
  >
158
- {this.state.addingNewSuppression ? "Cancel" : "Add Suppression"}
159
- </button>
160
- </div>
253
+ {this.state.showingAllErrors && "Show Less" || `Show More (${sortedErrors.length - 5} more)`}
254
+ </Button>
255
+ )}
256
+ </div>
161
257
 
162
- {this.state.addingNewSuppression && (
163
- <div className={css.vbox(8).pad2(12).bord2(120, 50, 60).hsl(120, 50, 95)}>
164
- <InputLabel
165
- label="Description"
166
- value={this.state.newSuppressionDescription}
167
- onChangeValue={(value) => {
168
- this.state.newSuppressionDescription = value;
169
- }}
170
- className={css.width(500)}
171
- />
172
- <InputLabel
173
- label="Pattern (regex)"
174
- value={this.state.newSuppressionPattern}
175
- onChangeValue={(value) => {
176
- this.state.newSuppressionPattern = value;
177
- }}
178
- className={css.width(500)}
179
- />
180
- <button
181
- className={css.pad2(12, 8).button.bord2(120, 80, 50).hsl(120, 80, 85)}
182
- onClick={() => {
183
- let description = this.state.newSuppressionDescription;
184
- let pattern = this.state.newSuppressionPattern;
185
- if (!description || !pattern) {
186
- alert("Description and pattern are required");
187
- return;
188
- }
258
+ <div className={css.vbox(12)}>
259
+ <h3>Suppressions ({sortedSuppressions.length})</h3>
189
260
 
190
- Querysub.onCommitFinished(async () => {
191
- let now = Date.now();
192
- let newEntry = {
193
- id: nextId(),
194
- description,
195
- pattern,
196
- createdTime: now,
197
- lastUpdatedTime: now,
198
- };
199
- let controller = ErrorNotificationsController(SocketFunction.browserNodeId());
200
- await controller.setSuppressionEntry.promise(newEntry);
201
-
202
- let suppressionEntries = await controller.getSuppressionEntries.promise();
203
- Querysub.commit(() => {
204
- this.state.suppressionEntries = suppressionEntries;
205
- this.state.addingNewSuppression = false;
206
- this.state.newSuppressionDescription = "";
207
- this.state.newSuppressionPattern = "";
261
+ <div className={css.vbox(8).pad2(12).bord2(120, 50, 60).hsl(120, 50, 95)}>
262
+ <InputLabel
263
+ label="Fixed"
264
+ value={this.state.newFixedPattern}
265
+ onChangeValue={(value) => {
266
+ this.state.newFixedPattern = value;
267
+ }}
268
+ onKeyDown={e => {
269
+ if (e.key === "Enter") {
270
+ let pattern = e.currentTarget.value.trim();
271
+ if (pattern) {
272
+ this.state.newFixedPattern = pattern;
273
+ let timeout = Date.now() + timeInDay * 2;
274
+ Querysub.onCommitFinished(async () => {
275
+ await this.manager.addSuppression(pattern, timeout);
276
+ Querysub.commit(() => {
277
+ this.state.newFixedPattern = "";
278
+ });
208
279
  });
209
- });
210
- }}
211
- >
212
- Save Suppression
213
- </button>
214
- </div>
215
- )}
280
+ }
281
+ }
282
+ }}
283
+ className={css.width(500)}
284
+ />
285
+ <InputLabel
286
+ label="Not a Bug"
287
+ value={this.state.newNotABugPattern}
288
+ onChangeValue={(value) => {
289
+ this.state.newNotABugPattern = value;
290
+ }}
291
+ onKeyDown={e => {
292
+ if (e.key === "Enter") {
293
+ let pattern = e.currentTarget.value.trim();
294
+ if (pattern) {
295
+ this.state.newNotABugPattern = pattern;
296
+ Querysub.onCommitFinished(async () => {
297
+ await this.manager.addSuppression(pattern, Infinity);
298
+ Querysub.commit(() => {
299
+ this.state.newNotABugPattern = "";
300
+ });
301
+ });
302
+ }
303
+ }
304
+ }}
305
+ className={css.width(500)}
306
+ />
307
+ </div>
216
308
 
217
309
  <div className={css.vbox(12)}>
218
310
  {sortedSuppressions.map((suppression) => {
219
- let matchData = this.state.suppressionMatches.get(suppression.id);
220
- let totalCount = 0;
221
- if (matchData) {
222
- for (let [chunk, data] of matchData.history) {
223
- totalCount += data.count;
224
- }
225
- }
311
+ let matchData = this.manager.state.suppressionMatches.get(suppression.id);
226
312
 
227
- return <div key={suppression.id} className={css.pad2(12).bord2(200, 30, 70).hsl(200, 30, 95).vbox(8)}>
228
- <div className={css.hbox(12)}>
229
- <div className={css.flexGrow(1).vbox(4)}>
230
- <strong>{suppression.description}</strong>
231
- <div className={css.hbox(12)}>
232
- <span>Pattern: <code className={css.pad2(4, 2).hsl(0, 0, 90)}>{suppression.pattern}</code></span>
233
- <span>Total Matches: {totalCount}</span>
234
- </div>
235
- <div className={css.hbox(12)}>
236
- <span>Created: {formatVeryNiceDateTime(suppression.createdTime)}</span>
237
- <span>Updated: {formatVeryNiceDateTime(suppression.lastUpdatedTime)}</span>
238
- </div>
239
- </div>
240
- <button
241
- className={css.pad2(8, 6).button.bord2(0, 80, 50).hsl(0, 80, 90)}
242
- onClick={() => {
243
- if (!confirm(`Delete suppression "${suppression.description}"?`)) return;
244
-
245
- Querysub.onCommitFinished(async () => {
246
- let controller = ErrorNotificationsController(SocketFunction.browserNodeId());
247
- await controller.deleteSuppressionEntry.promise(suppression.id);
248
-
249
- let suppressionEntries = await controller.getSuppressionEntries.promise();
250
- Querysub.commit(() => {
251
- this.state.suppressionEntries = suppressionEntries;
252
- });
253
- });
254
- }}
255
- >
256
- Delete
257
- </button>
258
- </div>
259
-
260
- {matchData && matchData.examples.length > 0 && (
261
- <div className={css.vbox(8)}>
262
- <strong>Recent Examples ({matchData.examples.length}):</strong>
263
- {matchData.examples.slice(0, 3).map((example, idx) => (
264
- <pre key={idx} className={css.overflowAuto.pad2(8).hsl(0, 0, 98).bord2(0, 0, 85).fontSize(12)}>
265
- {JSON.stringify(example, null, 2)}
266
- </pre>
267
- ))}
268
- </div>
269
- )}
270
- </div>;
313
+ return <SuppressionItem
314
+ key={suppression.id}
315
+ suppression={suppression}
316
+ matchData={matchData}
317
+ />;
271
318
  })}
272
319
  </div>
273
320
  </div>
274
321
  </div>;
275
322
  }
276
323
  }
324
+
325
+ class SuppressionItem extends qreact.Component<{
326
+ suppression: {
327
+ id: string;
328
+ notes?: string;
329
+ pattern: string;
330
+ timeout: number;
331
+ createdTime: number;
332
+ lastUpdatedTime: number;
333
+ };
334
+ matchData: {
335
+ history: Map<number, { count: number }>;
336
+ exampleIndex: number;
337
+ examples: LogDatum[];
338
+ } | undefined;
339
+ }> {
340
+ state = t.state({
341
+ showingAllExamples: t.atomic<boolean>(false),
342
+ patternValue: t.atomic<string>(""),
343
+ notesValue: t.atomic<string>(""),
344
+ });
345
+
346
+ manager = getErrorNotificationsManager();
347
+
348
+ componentDidMount() {
349
+ this.state.patternValue = this.props.suppression.pattern;
350
+ this.state.notesValue = this.props.suppression.notes ?? "";
351
+ }
352
+
353
+ componentDidUpdate(prevProps: any) {
354
+ if (prevProps.suppression.pattern !== this.props.suppression.pattern) {
355
+ this.state.patternValue = this.props.suppression.pattern;
356
+ }
357
+ if (prevProps.suppression.notes !== this.props.suppression.notes) {
358
+ this.state.notesValue = this.props.suppression.notes ?? "";
359
+ }
360
+ }
361
+
362
+ render() {
363
+ let suppression = this.props.suppression;
364
+ let matchData = this.props.matchData;
365
+ let totalCount = 0;
366
+ if (matchData) {
367
+ for (let [chunk, data] of matchData.history) {
368
+ totalCount += data.count;
369
+ }
370
+ }
371
+
372
+ let timeoutText: preact.ComponentChild;
373
+ if (suppression.timeout === Infinity) {
374
+ timeoutText = "Never expires";
375
+ } else if (suppression.timeout > Date.now()) {
376
+ timeoutText = <span>Expires <b>{formatTime(suppression.timeout - Date.now())}</b></span>;
377
+ } else {
378
+ timeoutText = <span>Expired <b>{formatTime(Date.now() - suppression.timeout)}</b> ago</span>;
379
+ }
380
+
381
+ return <div key={suppression.id} className={css.pad2(12).bord2(200, 30, 70).hsl(200, 30, 95).vbox(8)}>
382
+ <div className={css.hbox(12)}>
383
+ <ATag
384
+ values={[
385
+ managementPageURL.getOverride("LogViewer3"),
386
+ searchTextURL.getOverride(suppression.pattern),
387
+ readProductionLogsURL.getOverride(true),
388
+ ]}
389
+ >
390
+ View Logs
391
+ </ATag>
392
+ <Button
393
+ hue={30}
394
+ onClick={() => {
395
+ let timeout = Date.now() + timeInDay * 2;
396
+ Querysub.onCommitFinished(async () => {
397
+ await this.manager.updateTimeout(suppression.id, timeout);
398
+ });
399
+ }}
400
+ >
401
+ Fixed
402
+ </Button>
403
+ <Button
404
+ hue={120}
405
+ onClick={() => {
406
+ Querysub.onCommitFinished(async () => {
407
+ await this.manager.updateTimeout(suppression.id, Infinity);
408
+ });
409
+ }}
410
+ >
411
+ Not a Bug
412
+ </Button>
413
+ <Button
414
+ hue={30}
415
+ onClick={() => {
416
+ let timeout = Date.now();
417
+ Querysub.onCommitFinished(async () => {
418
+ await this.manager.updateTimeout(suppression.id, timeout);
419
+ });
420
+ }}
421
+ >
422
+ Expire Now
423
+ </Button>
424
+ <Button
425
+ hue={0}
426
+ onClick={() => {
427
+ if (!confirm(`Delete this suppression?`)) return;
428
+
429
+ Querysub.onCommitFinished(async () => {
430
+ await this.manager.deleteSuppression(suppression.id);
431
+ });
432
+ }}
433
+ >
434
+ Delete
435
+ </Button>
436
+ <div className={css.hbox(20)}>
437
+ <span><b>{formatNumber(totalCount)}</b> Matches</span>
438
+ <span>Updated <b>{formatTime(Date.now() - suppression.lastUpdatedTime)}</b> AGO</span>
439
+ <span>{timeoutText}</span>
440
+ </div>
441
+ </div>
442
+
443
+ <div className={css.hbox(20)}>
444
+ <InputLabel
445
+ label="Pattern"
446
+ value={this.state.patternValue}
447
+ onChangeValue={(value) => {
448
+ this.state.patternValue = value;
449
+ }}
450
+ onKeyDown={e => {
451
+ if (e.key === "Enter") {
452
+ let pattern = e.currentTarget.value.trim();
453
+ if (!pattern) {
454
+ alert("Pattern cannot be empty");
455
+ return;
456
+ }
457
+ this.state.patternValue = pattern;
458
+ Querysub.onCommitFinished(async () => {
459
+ await this.manager.updateSuppression(suppression.id, pattern);
460
+ });
461
+ }
462
+ }}
463
+ className={css.width(500)}
464
+ />
465
+ <InputLabel
466
+ label="Reason"
467
+ value={this.state.notesValue}
468
+ onChangeValue={(value) => {
469
+ this.state.notesValue = value;
470
+ }}
471
+ onKeyDown={e => {
472
+ if (e.key === "Enter") {
473
+ let notes = e.currentTarget.value.trim();
474
+ this.state.notesValue = notes;
475
+ Querysub.onCommitFinished(async () => {
476
+ await this.manager.updateNotes(suppression.id, notes && notes || undefined);
477
+ });
478
+ }
479
+ }}
480
+ className={css.width(500)}
481
+ />
482
+ </div>
483
+
484
+ {matchData && matchData.examples.length > 0 && (
485
+ <div className={css.hbox(8)}>
486
+ {matchData.examples.length > 1 && (
487
+ <Button
488
+ hue={200}
489
+ onClick={() => {
490
+ this.state.showingAllExamples = !this.state.showingAllExamples;
491
+ }}
492
+ >
493
+ {this.state.showingAllExamples && "Show Less" || `Show More (${matchData.examples.length - 1} more)`}
494
+ </Button>
495
+ )}
496
+ <div className={css.vbox(4)}>
497
+ {(this.state.showingAllExamples && matchData.examples || matchData.examples.slice(0, 1)).map((example, idx) => (
498
+ <LogDatumRenderer key={idx} hideSuppression datum={example} pattern={suppression.pattern} />
499
+ ))}
500
+ </div>
501
+ </div>
502
+ )}
503
+ </div>;
504
+ }
505
+ }