querysub 0.327.0 → 0.329.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.
Files changed (50) hide show
  1. package/bin/error-email.js +8 -0
  2. package/bin/error-im.js +8 -0
  3. package/package.json +4 -3
  4. package/src/-a-archives/archivesBackBlaze.ts +20 -0
  5. package/src/-a-archives/archivesCborT.ts +52 -0
  6. package/src/-a-archives/archivesDisk.ts +5 -5
  7. package/src/-a-archives/archivesJSONT.ts +19 -5
  8. package/src/-a-archives/archivesLimitedCache.ts +118 -7
  9. package/src/-a-archives/archivesPrivateFileSystem.ts +3 -0
  10. package/src/-g-core-values/NodeCapabilities.ts +26 -11
  11. package/src/0-path-value-core/auditLogs.ts +4 -2
  12. package/src/2-proxy/PathValueProxyWatcher.ts +7 -0
  13. package/src/3-path-functions/PathFunctionRunner.ts +2 -2
  14. package/src/4-querysub/Querysub.ts +1 -1
  15. package/src/5-diagnostics/GenericFormat.tsx +2 -2
  16. package/src/config.ts +15 -3
  17. package/src/deployManager/machineApplyMainCode.ts +10 -8
  18. package/src/deployManager/machineSchema.ts +4 -3
  19. package/src/deployManager/setupMachineMain.ts +3 -2
  20. package/src/diagnostics/logs/FastArchiveAppendable.ts +86 -53
  21. package/src/diagnostics/logs/FastArchiveController.ts +11 -2
  22. package/src/diagnostics/logs/FastArchiveViewer.tsx +205 -48
  23. package/src/diagnostics/logs/LogViewer2.tsx +78 -34
  24. package/src/diagnostics/logs/TimeRangeSelector.tsx +8 -0
  25. package/src/diagnostics/logs/diskLogGlobalContext.ts +5 -4
  26. package/src/diagnostics/logs/diskLogger.ts +70 -23
  27. package/src/diagnostics/logs/errorNotifications/ErrorDigestPage.tsx +409 -0
  28. package/src/diagnostics/logs/errorNotifications/ErrorNotificationController.ts +94 -67
  29. package/src/diagnostics/logs/errorNotifications/ErrorSuppressionUI.tsx +37 -3
  30. package/src/diagnostics/logs/errorNotifications/ErrorWarning.tsx +50 -16
  31. package/src/diagnostics/logs/errorNotifications/errorDigestEmail.tsx +174 -0
  32. package/src/diagnostics/logs/errorNotifications/errorDigests.tsx +291 -0
  33. package/src/diagnostics/logs/errorNotifications/errorLoopEntry.tsx +7 -0
  34. package/src/diagnostics/logs/errorNotifications/errorWatchEntry.tsx +185 -68
  35. package/src/diagnostics/logs/lifeCycleAnalysis/spec.md +10 -19
  36. package/src/diagnostics/managementPages.tsx +33 -15
  37. package/src/email_ims_notifications/discord.tsx +203 -0
  38. package/src/{email → email_ims_notifications}/postmark.tsx +3 -3
  39. package/src/fs.ts +9 -0
  40. package/src/functional/SocketChannel.ts +9 -0
  41. package/src/functional/throttleRender.ts +134 -0
  42. package/src/library-components/ATag.tsx +2 -2
  43. package/src/library-components/SyncedController.ts +3 -3
  44. package/src/misc.ts +18 -0
  45. package/src/misc2.ts +106 -0
  46. package/src/user-implementation/SecurityPage.tsx +11 -5
  47. package/src/user-implementation/userData.ts +57 -23
  48. package/testEntry2.ts +14 -5
  49. package/src/user-implementation/setEmailKey.ts +0 -25
  50. /package/src/{email → email_ims_notifications}/sendgrid.tsx +0 -0
@@ -6,6 +6,8 @@ import { timeInMinute } from "socket-function/src/misc";
6
6
  import { formatTime } from "socket-function/src/formatting/format";
7
7
  import { addEpsilons } from "../../bits";
8
8
  import { FileMetadata } from "./FastArchiveController";
9
+ import { getPathStr2 } from "../../path";
10
+ import { isPublic } from "../../config";
9
11
  // IMPORTANT! We can't have any real imports here, because we are depended on so early in startup!
10
12
 
11
13
  if (isNode()) {
@@ -35,7 +37,25 @@ export type LogDatum = Record<string, unknown> & {
35
37
  /** Dynamically set when matching recent errors only. */
36
38
  __matchedOutdatedSuppressionKey?: string;
37
39
  };
40
+ export function getLogHash(obj: LogDatum) {
41
+ return getPathStr2(obj.__threadId || "", obj.time.toString());
42
+ }
43
+ export function getLogFile(obj: LogDatum) {
44
+ let logType = obj.param0 || "";
45
+ if (obj.__FILE__) {
46
+ logType = String(obj.__FILE__);
47
+ }
48
+ if (obj[LOG_LINE_LIMIT_ID]) {
49
+ logType += "::" + String(obj[LOG_LINE_LIMIT_ID]);
50
+ }
51
+ return logType;
52
+
53
+ }
38
54
  export const LOG_LIMIT_FLAG = String.fromCharCode(44533) + "LOGS_LIMITED_FLAG-9277640b-d709-4591-ab08-2bb29bbb94f4";
55
+ export const LOG_LINE_LIMIT_FLAG = String.fromCharCode(44534) + "LOGS_LINE_LIMIT_FLAG-dd50ab1f-3021-45e3-82fc-d2702c7a64c8";
56
+
57
+ /** If this key exists in the logged object, as in a key in one of the objects logged, then we will use the value of it as the limit ID. This is useful as it allows us to either override a limit or limit something independently from other logs in the file. */
58
+ export const LOG_LINE_LIMIT_ID = "LIMIT_LINE_ID";
39
59
 
40
60
  export const getLoggers = lazy(function () {
41
61
  const { FastArchiveAppendable } = require("./FastArchiveAppendable") as typeof import("./FastArchiveAppendable");
@@ -52,6 +72,10 @@ export const getLoggers = lazy(function () {
52
72
  errorLogs: new FastArchiveAppendable<LogDatum>("logs-error/"),
53
73
  };
54
74
  });
75
+ setImmediate(() => {
76
+ // If we don't import it at all, then it doesn't work client-side.
77
+ require("./FastArchiveAppendable") as typeof import("./FastArchiveAppendable");
78
+ });
55
79
  const getNotifyErrors = lazy(function () {
56
80
  const { notifyWatchersOfError: notifyErrors } = require("./errorNotifications/ErrorNotificationController") as typeof import("./errorNotifications/ErrorNotificationController");
57
81
  if (typeof notifyErrors !== "function") {
@@ -88,6 +112,8 @@ let logLimitLookup: {
88
112
 
89
113
  const LIMIT_PERIOD = timeInMinute * 15;
90
114
  const LIMIT_THRESHOLD = 1000;
115
+ const WARN_LIMIT = 100;
116
+ const ERROR_LIMIT = 100;
91
117
 
92
118
  const logDiskDontShim = logDisk;
93
119
  /** NOTE: Calling this directly means we lose __FILE__ tracking. But... that's probably fine... */
@@ -104,6 +130,11 @@ export function logDisk(type: "log" | "warn" | "info" | "error", ...args: unknow
104
130
  if (logObj.__FILE__) {
105
131
  logType = String(logObj.__FILE__);
106
132
  }
133
+ let hasLineLimit = false;
134
+ if (logObj[LOG_LINE_LIMIT_ID]) {
135
+ logType += "::" + String(logObj[LOG_LINE_LIMIT_ID]);
136
+ hasLineLimit = true;
137
+ }
107
138
 
108
139
  if (logLimitLookup) {
109
140
  if (logObj.time > logLimitLookup.resetTime) {
@@ -120,32 +151,47 @@ export function logDisk(type: "log" | "warn" | "info" | "error", ...args: unknow
120
151
  let count = logLimitLookup.counts.get(logType) || 0;
121
152
  count++;
122
153
  logLimitLookup.counts.set(logType, count);
123
- if (count > LIMIT_THRESHOLD) {
154
+ let limit = LIMIT_THRESHOLD;
155
+ if (type === "warn") {
156
+ limit = WARN_LIMIT;
157
+ } else if (type === "error") {
158
+ limit = ERROR_LIMIT;
159
+ }
160
+ if (count > limit) {
124
161
  let timeUntilReset = logLimitLookup.resetTime - logObj.time;
125
- process.stdout.write(`Log type hit limit, not writing log type to disk for ~${formatTime(timeUntilReset)}: ${logType}\n`);
162
+ if (hasLineLimit) {
163
+ process.stdout.write(`Log type hit limit, not writing log type to disk for ~${formatTime(timeUntilReset)}: ${logType}\n`);
164
+ }
126
165
  return;
127
166
  }
128
- if (count === LIMIT_THRESHOLD) {
129
- logObj[LOG_LIMIT_FLAG] = true;
167
+ if (count >= limit) {
168
+ if (hasLineLimit) {
169
+ logObj[LOG_LINE_LIMIT_FLAG] = true;
170
+ } else {
171
+ logObj[LOG_LIMIT_FLAG] = true;
172
+ }
130
173
  }
131
174
 
132
- let loggers = startupDone ? getLoggers() : undefined;
133
- if (!loggers) {
134
- getLoggers.reset();
135
- setImmediate(() => {
136
- logDiskDontShim(type, ...args);
137
- });
138
- return;
139
- }
140
- const { logLogs, warnLogs, infoLogs, errorLogs } = loggers;
141
- if (type === "log") {
142
- logLogs.append(logObj);
143
- } else if (type === "warn") {
144
- warnLogs.append(logObj);
145
- } else if (type === "info") {
146
- infoLogs.append(logObj);
147
- } else {
148
- errorLogs.append(logObj);
175
+ // We don't want developer errors clogging up the error logs. However, they can still notify errors, Because this will only notify nodes that are able to access us (It uses a reverse connection scheme, so instead of talking to nodes that we can access, we only talk to nodes that can access us), Which will mean it will only notify for local services, so the developer still gets error notifications, But our errors won't be spread to all developers. BUT, we will still watch global errors, because we can contact the global server, so developers will still get errors about production issues, even while developing!
176
+ if (isPublic()) {
177
+ let loggers = startupDone ? getLoggers() : undefined;
178
+ if (!loggers) {
179
+ getLoggers.reset();
180
+ setImmediate(() => {
181
+ logDiskDontShim(type, ...args);
182
+ });
183
+ return;
184
+ }
185
+ const { logLogs, warnLogs, infoLogs, errorLogs } = loggers;
186
+ if (type === "log") {
187
+ logLogs.append(logObj);
188
+ } else if (type === "warn") {
189
+ warnLogs.append(logObj);
190
+ } else if (type === "info") {
191
+ infoLogs.append(logObj);
192
+ } else {
193
+ errorLogs.append(logObj);
194
+ }
149
195
  }
150
196
 
151
197
  if (type === "warn" || type === "error") {
@@ -162,12 +208,12 @@ let lastLogTime = 0;
162
208
 
163
209
  function packageLogObj(type: string, args: unknown[]): LogDatum {
164
210
  let now = Date.now();
165
- if (now < lastLogTime) {
211
+ if (now <= lastLogTime) {
166
212
  now = addEpsilons(lastLogTime, 1);
167
213
  }
168
214
  lastLogTime = now;
169
215
  let logObj: LogDatum = {
170
- time: now,
216
+ time: 0,
171
217
  __LOG_TYPE: type,
172
218
  };
173
219
  for (let part of globalContextParts) {
@@ -184,5 +230,6 @@ function packageLogObj(type: string, args: unknown[]): LogDatum {
184
230
  stringCount++;
185
231
  }
186
232
  }
233
+ logObj.time = now;
187
234
  return logObj;
188
235
  }
@@ -0,0 +1,409 @@
1
+ module.noserverhotreload = false;
2
+ module.hotreload = true;
3
+
4
+
5
+ import { SocketFunction } from "socket-function/SocketFunction";
6
+ import { qreact } from "../../../4-dom/qreact";
7
+ import { getSyncedController } from "../../../library-components/SyncedController";
8
+ import { ErrorDigestController, errorDigestHistory, ErrorDigestInfo } from "./errorDigests";
9
+ import { isManagementUser } from "../../../-0-hooks/hooks";
10
+ import { assertIsManagementUser } from "../../managementPages";
11
+ import { URLParam } from "../../../library-components/URLParam";
12
+ import { tabURL } from "../../../library-components/urlResetGroups";
13
+ import { Anchor, ATag } from "../../../library-components/ATag";
14
+ import { css } from "../../../4-dom/css";
15
+ import { list, sort } from "socket-function/src/misc";
16
+ import { formatDateTime, formatVeryNiceDateTime, formatNumber } from "socket-function/src/formatting/format";
17
+ import { Table } from "../../../5-diagnostics/Table";
18
+ import { Histogram } from "../../../library-components/Histogram";
19
+ import { TabbedUI } from "../../../library-components/TabbedUI";
20
+ import { LogDatum } from "../diskLogger";
21
+ import { t } from "../../../2-proxy/schema2";
22
+ import { getErrorLogsLink } from "./ErrorWarning";
23
+
24
+ export const digestKeyURL = new URLParam("digestKey", "");
25
+ export const fileDetailTabURL = new URLParam("fileDetailTab", "errors");
26
+
27
+ export class ErrorDigestPage extends qreact.Component {
28
+ state = t.state({
29
+ selectedFile: t.string("")
30
+ });
31
+ render() {
32
+ // Get the current tab mode
33
+ let currentTab = tabURL.value;
34
+ let selectedDigestKey = digestKeyURL.value;
35
+
36
+ if (currentTab === "detail" && selectedDigestKey) {
37
+ return this.renderDigestDetail(selectedDigestKey);
38
+ }
39
+
40
+ return this.renderDigestList();
41
+ }
42
+
43
+ renderDigestList() {
44
+ let controller = ErrorDigestController(SocketFunction.browserNodeId());
45
+
46
+ let keys = controller.getDigestKeys();
47
+
48
+ if (!keys || keys.length === 0) {
49
+ return (
50
+ <div className={css.vbox(16).pad2(24)}>
51
+ <div className={css.fontSize(24).fontWeight(600)}>
52
+ Error Digest History
53
+ </div>
54
+ <div>No digest data available</div>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ // Sort lexicographically (highest = newest) and take latest 100
60
+ let sortedKeys = sort(keys as string[], key => key).reverse().slice(0, 100);
61
+
62
+ return (
63
+ <div className={css.vbox(16).paddingTop(24).paddingLeft(24).fillHeight.fillWidth}>
64
+ <div className={css.fontSize(24).fontWeight(600)}>
65
+ Error Digest History
66
+ </div>
67
+ <div className={css.fontSize(14).color("gray")}>
68
+ Showing latest {Math.min(sortedKeys.length, 100)} digests (sorted by creation time, newest first)
69
+ </div>
70
+
71
+ <div className={css.vbox(8).overflowAuto.fillWidth}>
72
+ {sortedKeys.map(key => (
73
+ <ATag
74
+ key={key}
75
+ values={[
76
+ tabURL.getOverride("detail"),
77
+ digestKeyURL.getOverride(key)
78
+ ]}
79
+ className={css.pad2(12).hsla(0, 0, 0, 0.1).pointer.hslahover(0, 0, 0, 0.15)}
80
+ >
81
+ {formatVeryNiceDateTime(+key.split(".")[0])}
82
+ </ATag>
83
+ ))}
84
+ </div>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ renderDigestDetail(digestKey: string) {
90
+ let controller = ErrorDigestController(SocketFunction.browserNodeId());
91
+
92
+ let digestData = controller.getDigest(digestKey);
93
+
94
+ if (!digestData) {
95
+ return (
96
+ <div className={css.vbox(16).pad2(24)}>
97
+ <div className={css.hbox(16)}>
98
+ <ATag
99
+ values={[
100
+ tabURL.getOverride(""),
101
+ digestKeyURL.getOverride("")
102
+ ]}
103
+ >
104
+ ← Back to Digest List
105
+ </ATag>
106
+ </div>
107
+ <div>Loading digest data...</div>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ // If a file is selected, show file details
113
+ if (this.state.selectedFile) {
114
+ return this.renderFileDetail(digestData, digestKey);
115
+ }
116
+
117
+ return (
118
+ <div className={css.vbox(24).pad2(24).fillHeight.overflowAuto}>
119
+ {/* Header */}
120
+ <div className={css.hbox(16).alignItems("center")}>
121
+ <ATag
122
+ values={[
123
+ tabURL.getOverride(""),
124
+ digestKeyURL.getOverride("")
125
+ ]}
126
+ >
127
+ ← Back to Digest List
128
+ </ATag>
129
+ <div className={css.fontSize(24).fontWeight(600)}>
130
+ Digest: {formatVeryNiceDateTime(+digestKey.split(".")[0])}
131
+ </div>
132
+ </div>
133
+
134
+ <Anchor
135
+ className={css.fontSize(24)}
136
+ values={getErrorLogsLink({
137
+ startTime: digestData.startTime,
138
+ endTime: digestData.endTime,
139
+ })}
140
+ >
141
+ View live logs
142
+ </Anchor>
143
+
144
+ {/* Metadata Section */}
145
+ {this.renderMetadata(digestData)}
146
+
147
+ {/* Charts Section */}
148
+ {this.renderCharts(digestData)}
149
+
150
+ {/* Files Table */}
151
+ {this.renderFilesTable(digestData)}
152
+ </div>
153
+ );
154
+ }
155
+
156
+ renderMetadata(digestData: ErrorDigestInfo) {
157
+ return (
158
+ <div className={css.vbox(16)}>
159
+ <div className={css.fontSize(20).fontWeight(600)}>Metadata</div>
160
+ <div className={css.hbox(32).wrap}>
161
+ <div className={css.vbox(4)}>
162
+ <div className={css.fontSize(14).colorhsl(0, 0, 60)}>Compressed Bytes</div>
163
+ <div className={css.fontSize(16).fontWeight(500)}>
164
+ {formatNumber(digestData.totalCompressedBytes)}
165
+ </div>
166
+ </div>
167
+ <div className={css.vbox(4)}>
168
+ <div className={css.fontSize(14).colorhsl(0, 0, 60)}>Uncompressed Bytes</div>
169
+ <div className={css.fontSize(16).fontWeight(500)}>
170
+ {formatNumber(digestData.totalUncompressedBytes)}
171
+ </div>
172
+ </div>
173
+ <div className={css.vbox(4)}>
174
+ <div className={css.fontSize(14).colorhsl(0, 0, 60)}>Total Files</div>
175
+ <div className={css.fontSize(16).fontWeight(500)}>
176
+ {formatNumber(digestData.totalFiles)}
177
+ </div>
178
+ </div>
179
+ <div className={css.vbox(4)}>
180
+ <div className={css.fontSize(14).colorhsl(0, 0, 60)}>Scan Duration</div>
181
+ <div className={css.fontSize(16).fontWeight(500)}>
182
+ {formatNumber(digestData.scanDuration)}ms
183
+ </div>
184
+ </div>
185
+ <div className={css.vbox(4)}>
186
+ <div className={css.fontSize(14).colorhsl(0, 0, 60)}>Start Time</div>
187
+ <div className={css.fontSize(16).fontWeight(500)}>
188
+ {formatVeryNiceDateTime(digestData.startTime)}
189
+ </div>
190
+ </div>
191
+ <div className={css.vbox(4)}>
192
+ <div className={css.fontSize(14).colorhsl(0, 0, 60)}>End Time</div>
193
+ <div className={css.fontSize(16).fontWeight(500)}>
194
+ {formatVeryNiceDateTime(digestData.endTime)}
195
+ </div>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ );
200
+ }
201
+
202
+ renderCharts(digestData: ErrorDigestInfo) {
203
+ // Convert histogram data to chart data
204
+ let unsuppressedErrorsData: { x: number; y: number }[] = [];
205
+ let unsuppressedWarningsData: { x: number; y: number }[] = [];
206
+ let corruptData: { x: number; y: number }[] = [];
207
+ let suppressedData: { x: number; y: number }[] = [];
208
+
209
+ for (let [time, data] of digestData.histogram.entries()) {
210
+ unsuppressedErrorsData.push({ x: time, y: data.unsuppressedErrors });
211
+ unsuppressedWarningsData.push({ x: time, y: data.unsuppressedWarnings });
212
+ corruptData.push({ x: time, y: data.corruptErrors + data.corruptWarnings });
213
+ suppressedData.push({ x: time, y: data.suppressedErrors + data.suppressedWarnings });
214
+ }
215
+
216
+ return (
217
+ <div className={css.vbox(16)}>
218
+ <div className={css.fontSize(20).fontWeight(600)}>Error Distribution Over Time</div>
219
+ <div className={css.vbox(16)}>
220
+ <div className={css.hbox(16).wrap}>
221
+ <div className={css.fillWidth.minWidth(400).height(300)}>
222
+ <Histogram
223
+ title="Unsuppressed Errors"
224
+ values={unsuppressedErrorsData}
225
+ xColumn={{
226
+ title: "Time",
227
+ format: (value) => formatDateTime(value)
228
+ }}
229
+ yColumn={{ title: "Count" }}
230
+ />
231
+ </div>
232
+ <div className={css.fillWidth.minWidth(400).height(300)}>
233
+ <Histogram
234
+ title="Unsuppressed Warnings"
235
+ values={unsuppressedWarningsData}
236
+ xColumn={{
237
+ title: "Time",
238
+ format: (value) => formatDateTime(value)
239
+ }}
240
+ yColumn={{ title: "Count" }}
241
+ />
242
+ </div>
243
+ </div>
244
+ <div className={css.hbox(16).wrap}>
245
+ <div className={css.fillWidth.minWidth(400).height(300)}>
246
+ <Histogram
247
+ title="Corrupt Errors + Warnings"
248
+ values={corruptData}
249
+ xColumn={{
250
+ title: "Time",
251
+ format: (value) => formatDateTime(value)
252
+ }}
253
+ yColumn={{ title: "Count" }}
254
+ />
255
+ </div>
256
+ <div className={css.fillWidth.minWidth(400).height(300)}>
257
+ <Histogram
258
+ title="Suppressed Errors + Warnings"
259
+ values={suppressedData}
260
+ xColumn={{
261
+ title: "Time",
262
+ format: (value) => formatDateTime(value)
263
+ }}
264
+ yColumn={{ title: "Count" }}
265
+ />
266
+ </div>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ );
271
+ }
272
+
273
+ renderFilesTable(digestData: ErrorDigestInfo) {
274
+ // Convert byFile map to table rows
275
+ let fileRows = Array.from(digestData.byFile.entries()).map(([fileName, fileData]) => {
276
+ let latestError = fileData.latestErrors[fileData.latestErrors.length - 1];
277
+ let latestWarning = fileData.latestWarnings[fileData.latestWarnings.length - 1];
278
+ let latestLog = latestError ?? latestWarning;
279
+
280
+ return {
281
+ fileName,
282
+ errorCount: fileData.errors,
283
+ warningCount: fileData.warnings,
284
+ latestMessage: latestLog?.param0 ?? "No messages",
285
+ latestTime: latestLog?.time ?? 0
286
+ };
287
+ });
288
+
289
+ // Sort by error count descending
290
+ fileRows = sort(fileRows, row => row.errorCount).reverse();
291
+
292
+ return (
293
+ <div className={css.vbox(16)}>
294
+ <div className={css.fontSize(20).fontWeight(600)}>Files ({fileRows.length} files)</div>
295
+ <Table
296
+ columns={{
297
+ fileName: { title: "File Name" },
298
+ errorCount: { title: "Errors" },
299
+ warningCount: { title: "Warnings" },
300
+ latestMessage: {
301
+ title: "Latest Message",
302
+ formatter: (value: any) => String(value).slice(0, 100) + (String(value).length > 100 ? "..." : "")
303
+ }
304
+ }}
305
+ rows={fileRows}
306
+ getRowAttributes={(row: any) => ({
307
+ className: css.cursor("pointer").hslhover(210, 20, 95),
308
+ onClick: () => {
309
+ this.state.selectedFile = row.fileName;
310
+ }
311
+ })}
312
+ />
313
+ </div>
314
+ );
315
+ }
316
+
317
+ renderFileDetail(digestData: ErrorDigestInfo, digestKey: string) {
318
+ let selectedFileName = this.state.selectedFile;
319
+ let fileData = digestData.byFile.get(selectedFileName);
320
+
321
+ if (!fileData) {
322
+ return (
323
+ <div className={css.vbox(16).pad2(24)}>
324
+ <div>File not found</div>
325
+ </div>
326
+ );
327
+ }
328
+
329
+ return (
330
+ <div className={css.vbox(24).pad2(24).fillHeight}>
331
+ {/* Header with back button */}
332
+ <div className={css.hbox(16).alignItems("center")}>
333
+ <ATag
334
+ clickOverride={() => {
335
+ this.state.selectedFile = "";
336
+ }}
337
+ className={css.cursor("pointer")}
338
+ >
339
+ ← Back to Files Table
340
+ </ATag>
341
+ <div className={css.fontSize(20).fontWeight(600)}>
342
+ {selectedFileName}
343
+ </div>
344
+ </div>
345
+
346
+ {/* File stats */}
347
+ <div className={css.hbox(32)}>
348
+ <div className={css.vbox(4)}>
349
+ <div className={css.fontSize(14).colorhsl(0, 0, 60)}>Errors</div>
350
+ <div className={css.fontSize(16).fontWeight(500)}>{fileData.errors}</div>
351
+ </div>
352
+ <div className={css.vbox(4)}>
353
+ <div className={css.fontSize(14).colorhsl(0, 0, 60)}>Warnings</div>
354
+ <div className={css.fontSize(16).fontWeight(500)}>{fileData.warnings}</div>
355
+ </div>
356
+ </div>
357
+
358
+ {/* Tabbed interface for errors/warnings */}
359
+ <div className={css.fillHeight.overflowHidden}>
360
+ <TabbedUI
361
+ tab={fileDetailTabURL}
362
+ tabs={[
363
+ {
364
+ value: "errors",
365
+ title: `Errors (${fileData.errors})`,
366
+ contents: this.renderLogEntries(fileData.latestErrors, "errors")
367
+ },
368
+ {
369
+ value: "warnings",
370
+ title: `Warnings (${fileData.warnings})`,
371
+ contents: this.renderLogEntries(fileData.latestWarnings, "warnings")
372
+ }
373
+ ]}
374
+ />
375
+ </div>
376
+ </div>
377
+ );
378
+ }
379
+
380
+ renderLogEntries(logs: LogDatum[], type: "errors" | "warnings") {
381
+ if (logs.length === 0) {
382
+ return <div className={css.colorhsl(0, 0, 60)}>No {type} found</div>;
383
+ }
384
+
385
+ let tableRows = logs.map((log, index) => ({
386
+ index: logs.length - index,
387
+ time: formatVeryNiceDateTime(log.time),
388
+ message: log.param0 ?? "No message",
389
+ threadId: log.__threadId ?? "Unknown",
390
+ name: log.__NAME__ ?? "Unknown"
391
+ }));
392
+
393
+ return (
394
+ <div className={css.fillHeight.overflowAuto}>
395
+ <Table
396
+ columns={{
397
+ index: { title: "#" },
398
+ time: { title: "Time" },
399
+ name: { title: "Source" },
400
+ threadId: { title: "Thread" },
401
+ message: { title: "Message" }
402
+ }}
403
+ rows={tableRows}
404
+ initialLimit={50}
405
+ />
406
+ </div>
407
+ );
408
+ }
409
+ }