@oneuptime/common 11.0.12 → 11.0.13
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/Models/DatabaseModels/StatusPage.ts +0 -42
- package/Server/Infrastructure/Postgres/SchemaMigrations/1782700000000-RemoveDeprecatedEnableSubscribersFromStatusPage.ts +35 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Infrastructure/Queue.ts +122 -0
- package/Server/Types/Markdown.ts +103 -0
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +4 -1
- package/Server/Utils/CodeRepository/CodeRepository.ts +78 -0
- package/Server/Utils/Execute.ts +6 -0
- package/Server/Utils/Logger.ts +66 -0
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +75 -0
- package/build/dist/Models/DatabaseModels/StatusPage.js +0 -44
- package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782700000000-RemoveDeprecatedEnableSubscribersFromStatusPage.js +28 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782700000000-RemoveDeprecatedEnableSubscribersFromStatusPage.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/Queue.js +63 -0
- package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
- package/build/dist/Server/Types/Markdown.js +87 -0
- package/build/dist/Server/Types/Markdown.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +3 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/CodeRepository/CodeRepository.js +60 -0
- package/build/dist/Server/Utils/CodeRepository/CodeRepository.js.map +1 -1
- package/build/dist/Server/Utils/Execute.js +1 -3
- package/build/dist/Server/Utils/Execute.js.map +1 -1
- package/build/dist/Server/Utils/Logger.js +49 -1
- package/build/dist/Server/Utils/Logger.js.map +1 -1
- package/package.json +1 -1
|
@@ -1243,48 +1243,6 @@ export default class StatusPage extends BaseModel {
|
|
|
1243
1243
|
})
|
|
1244
1244
|
public showScheduledEventLabelsOnStatusPage?: boolean = undefined;
|
|
1245
1245
|
|
|
1246
|
-
// This column is Deprectaed.
|
|
1247
|
-
@ColumnAccessControl({
|
|
1248
|
-
create: [
|
|
1249
|
-
Permission.ProjectOwner,
|
|
1250
|
-
Permission.ProjectAdmin,
|
|
1251
|
-
Permission.ProjectMember,
|
|
1252
|
-
Permission.StatusPageAdmin,
|
|
1253
|
-
Permission.StatusPageMember,
|
|
1254
|
-
Permission.CreateProjectStatusPage,
|
|
1255
|
-
],
|
|
1256
|
-
read: [
|
|
1257
|
-
Permission.ProjectOwner,
|
|
1258
|
-
Permission.ProjectAdmin,
|
|
1259
|
-
Permission.ProjectMember,
|
|
1260
|
-
Permission.Viewer,
|
|
1261
|
-
Permission.StatusPageAdmin,
|
|
1262
|
-
Permission.StatusPageMember,
|
|
1263
|
-
Permission.StatusPageViewer,
|
|
1264
|
-
Permission.ReadProjectStatusPage,
|
|
1265
|
-
],
|
|
1266
|
-
update: [
|
|
1267
|
-
Permission.ProjectOwner,
|
|
1268
|
-
Permission.ProjectAdmin,
|
|
1269
|
-
Permission.ProjectMember,
|
|
1270
|
-
Permission.StatusPageAdmin,
|
|
1271
|
-
Permission.StatusPageMember,
|
|
1272
|
-
Permission.EditProjectStatusPage,
|
|
1273
|
-
],
|
|
1274
|
-
})
|
|
1275
|
-
@TableColumn({
|
|
1276
|
-
isDefaultValueColumn: true,
|
|
1277
|
-
type: TableColumnType.Boolean,
|
|
1278
|
-
title: "Enable Subscribers",
|
|
1279
|
-
description: "Can subscribers subscribe to this Status Page?",
|
|
1280
|
-
defaultValue: true,
|
|
1281
|
-
})
|
|
1282
|
-
@Column({
|
|
1283
|
-
type: ColumnType.Boolean,
|
|
1284
|
-
default: true,
|
|
1285
|
-
})
|
|
1286
|
-
public enableSubscribers?: boolean = undefined;
|
|
1287
|
-
|
|
1288
1246
|
@ColumnAccessControl({
|
|
1289
1247
|
create: [
|
|
1290
1248
|
Permission.ProjectOwner,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { MigrationInterface, QueryRunner } from "typeorm";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Drops the deprecated StatusPage.enableSubscribers column.
|
|
5
|
+
*
|
|
6
|
+
* enableSubscribers was the old master "Can subscribers subscribe?" toggle,
|
|
7
|
+
* superseded by showSubscriberPageOnStatusPage + the per-channel
|
|
8
|
+
* enableEmailSubscribers / enableSmsSubscribers / enableSlackSubscribers flags.
|
|
9
|
+
* It was already marked deprecated in the model and gated nothing at runtime —
|
|
10
|
+
* its only consumer was a dashboard warning banner that wrongly claimed "no
|
|
11
|
+
* notifications will be sent" when it was off. That banner has been corrected,
|
|
12
|
+
* so this column now has no readers and is safe to remove.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: the SQL below is exactly what `npm run generate-postgres-migration`
|
|
15
|
+
* produced for this model change; the generator additionally emitted unrelated
|
|
16
|
+
* cosmetic JSON-default churn on OnCallDutyPolicyScheduleLayer, which was
|
|
17
|
+
* stripped so this migration only does the intended drop.
|
|
18
|
+
*/
|
|
19
|
+
export class RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000
|
|
20
|
+
implements MigrationInterface
|
|
21
|
+
{
|
|
22
|
+
public name = "RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000";
|
|
23
|
+
|
|
24
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
25
|
+
await queryRunner.query(
|
|
26
|
+
`ALTER TABLE "StatusPage" DROP COLUMN "enableSubscribers"`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
31
|
+
await queryRunner.query(
|
|
32
|
+
`ALTER TABLE "StatusPage" ADD "enableSubscribers" boolean NOT NULL DEFAULT true`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -398,6 +398,7 @@ import { MigrationName1782310000000 } from "./1782310000000-MigrationName";
|
|
|
398
398
|
import { RemoveIsTestedFromGlobalSsoAndOidc1782400000000 } from "./1782400000000-RemoveIsTestedFromGlobalSsoAndOidc";
|
|
399
399
|
import { OptimizeTelemetryExceptionWritePath1782500000000 } from "./1782500000000-OptimizeTelemetryExceptionWritePath";
|
|
400
400
|
import { AddArchiveToResources1782600000000 } from "./1782600000000-AddArchiveToResources";
|
|
401
|
+
import { RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000 } from "./1782700000000-RemoveDeprecatedEnableSubscribersFromStatusPage";
|
|
401
402
|
|
|
402
403
|
export default [
|
|
403
404
|
InitialMigration,
|
|
@@ -800,4 +801,5 @@ export default [
|
|
|
800
801
|
RemoveIsTestedFromGlobalSsoAndOidc1782400000000,
|
|
801
802
|
OptimizeTelemetryExceptionWritePath1782500000000,
|
|
802
803
|
AddArchiveToResources1782600000000,
|
|
804
|
+
RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000,
|
|
803
805
|
];
|
|
@@ -440,4 +440,126 @@ export default class Queue {
|
|
|
440
440
|
return result;
|
|
441
441
|
});
|
|
442
442
|
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Like getFailedJobs, but returns the FULL job for deep debugging: the job
|
|
446
|
+
* body (data), its options, return value, progress, all the timing/attempt
|
|
447
|
+
* metadata, and the per-job log lines BullMQ keeps (job.log()/getJobLogs).
|
|
448
|
+
* Used by the master-admin health dashboard / support bundle. The caller is
|
|
449
|
+
* responsible for redacting / size-capping before exposing this, since the
|
|
450
|
+
* job body can contain customer data.
|
|
451
|
+
*/
|
|
452
|
+
@CaptureSpan()
|
|
453
|
+
public static async getFailedJobsWithDetails(
|
|
454
|
+
queueName: QueueName,
|
|
455
|
+
options?: {
|
|
456
|
+
start?: number;
|
|
457
|
+
end?: number;
|
|
458
|
+
},
|
|
459
|
+
): Promise<
|
|
460
|
+
Array<{
|
|
461
|
+
id: string;
|
|
462
|
+
name: string;
|
|
463
|
+
data: JSONObject;
|
|
464
|
+
opts: JSONObject;
|
|
465
|
+
returnValue: unknown;
|
|
466
|
+
progress: number | Record<string, unknown> | null;
|
|
467
|
+
failedReason: string;
|
|
468
|
+
stackTrace: Array<string>;
|
|
469
|
+
logs: Array<string>;
|
|
470
|
+
attemptsMade: number;
|
|
471
|
+
attemptsStarted: number | null;
|
|
472
|
+
stalledCounter: number | null;
|
|
473
|
+
priority: number | null;
|
|
474
|
+
delay: number | null;
|
|
475
|
+
createdAt: Date | null;
|
|
476
|
+
processedOn: Date | null;
|
|
477
|
+
finishedOn: Date | null;
|
|
478
|
+
queueQualifiedName: string | null;
|
|
479
|
+
repeatJobKey: string | null;
|
|
480
|
+
deduplicationId: string | null;
|
|
481
|
+
processedBy: string | null;
|
|
482
|
+
parentKey: string | null;
|
|
483
|
+
}>
|
|
484
|
+
> {
|
|
485
|
+
const queue: BullQueue = this.getQueue(queueName);
|
|
486
|
+
const start: number = options?.start || 0;
|
|
487
|
+
const end: number = options?.end ?? 100;
|
|
488
|
+
const failed: Job[] = await queue.getFailed(start, end);
|
|
489
|
+
|
|
490
|
+
const results: Array<{
|
|
491
|
+
id: string;
|
|
492
|
+
name: string;
|
|
493
|
+
data: JSONObject;
|
|
494
|
+
opts: JSONObject;
|
|
495
|
+
returnValue: unknown;
|
|
496
|
+
progress: number | Record<string, unknown> | null;
|
|
497
|
+
failedReason: string;
|
|
498
|
+
stackTrace: Array<string>;
|
|
499
|
+
logs: Array<string>;
|
|
500
|
+
attemptsMade: number;
|
|
501
|
+
attemptsStarted: number | null;
|
|
502
|
+
stalledCounter: number | null;
|
|
503
|
+
priority: number | null;
|
|
504
|
+
delay: number | null;
|
|
505
|
+
createdAt: Date | null;
|
|
506
|
+
processedOn: Date | null;
|
|
507
|
+
finishedOn: Date | null;
|
|
508
|
+
queueQualifiedName: string | null;
|
|
509
|
+
repeatJobKey: string | null;
|
|
510
|
+
deduplicationId: string | null;
|
|
511
|
+
processedBy: string | null;
|
|
512
|
+
parentKey: string | null;
|
|
513
|
+
}> = [];
|
|
514
|
+
|
|
515
|
+
for (const job of failed) {
|
|
516
|
+
// Per-job log lines are best-effort — older jobs may have none.
|
|
517
|
+
let logs: Array<string> = [];
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
if (job.id) {
|
|
521
|
+
const jobLogs: { logs: string[]; count: number } =
|
|
522
|
+
await queue.getJobLogs(job.id, 0, -1);
|
|
523
|
+
logs = jobLogs?.logs || [];
|
|
524
|
+
}
|
|
525
|
+
} catch (err) {
|
|
526
|
+
logger.debug(`Failed to read logs for job ${job.id} on ${queueName}`);
|
|
527
|
+
logger.debug(err);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
results.push({
|
|
531
|
+
id: job.id || "unknown",
|
|
532
|
+
name: job.name || "unknown",
|
|
533
|
+
data: (job.data as JSONObject) || {},
|
|
534
|
+
opts: (job.opts as unknown as JSONObject) || {},
|
|
535
|
+
returnValue: job.returnvalue ?? null,
|
|
536
|
+
progress: (job.progress as number | Record<string, unknown>) ?? null,
|
|
537
|
+
failedReason: job.failedReason || "No reason provided",
|
|
538
|
+
stackTrace: job.stacktrace || [],
|
|
539
|
+
logs: logs,
|
|
540
|
+
attemptsMade: job.attemptsMade || 0,
|
|
541
|
+
attemptsStarted:
|
|
542
|
+
typeof job.attemptsStarted === "number" ? job.attemptsStarted : null,
|
|
543
|
+
stalledCounter:
|
|
544
|
+
typeof job.stalledCounter === "number" ? job.stalledCounter : null,
|
|
545
|
+
priority: typeof job.priority === "number" ? job.priority : null,
|
|
546
|
+
delay: typeof job.delay === "number" ? job.delay : null,
|
|
547
|
+
createdAt:
|
|
548
|
+
typeof job.timestamp === "number" ? new Date(job.timestamp) : null,
|
|
549
|
+
processedOn:
|
|
550
|
+
typeof job.processedOn === "number"
|
|
551
|
+
? new Date(job.processedOn)
|
|
552
|
+
: null,
|
|
553
|
+
finishedOn:
|
|
554
|
+
typeof job.finishedOn === "number" ? new Date(job.finishedOn) : null,
|
|
555
|
+
queueQualifiedName: job.queueQualifiedName || null,
|
|
556
|
+
repeatJobKey: job.repeatJobKey || null,
|
|
557
|
+
deduplicationId: job.deduplicationId || null,
|
|
558
|
+
processedBy: job.processedBy || null,
|
|
559
|
+
parentKey: job.parentKey || null,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return results;
|
|
564
|
+
}
|
|
443
565
|
}
|
package/Server/Types/Markdown.ts
CHANGED
|
@@ -7,6 +7,8 @@ export enum MarkdownContentType {
|
|
|
7
7
|
Docs,
|
|
8
8
|
Blog,
|
|
9
9
|
Email,
|
|
10
|
+
// Compact rendering tuned for small surfaces (e.g. the blog validation modal).
|
|
11
|
+
BlogValidation,
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export default class Markdown {
|
|
@@ -73,6 +75,7 @@ export default class Markdown {
|
|
|
73
75
|
private static blogRenderer: Renderer | null = null;
|
|
74
76
|
private static docsRenderer: Renderer | null = null;
|
|
75
77
|
private static emailRenderer: Renderer | null = null;
|
|
78
|
+
private static blogValidationRenderer: Renderer | null = null;
|
|
76
79
|
|
|
77
80
|
@CaptureSpan()
|
|
78
81
|
public static async convertToHTML(
|
|
@@ -93,6 +96,10 @@ export default class Markdown {
|
|
|
93
96
|
renderer = this.getEmailRenderer();
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
if (contentType === MarkdownContentType.BlogValidation) {
|
|
100
|
+
renderer = this.getBlogValidationRenderer();
|
|
101
|
+
}
|
|
102
|
+
|
|
96
103
|
/*
|
|
97
104
|
* Escape raw HTML tokens in the markdown source to prevent XSS.
|
|
98
105
|
* This only affects raw HTML that users type directly (e.g. <img onerror=...>),
|
|
@@ -132,6 +139,102 @@ export default class Markdown {
|
|
|
132
139
|
return renderer;
|
|
133
140
|
}
|
|
134
141
|
|
|
142
|
+
/*
|
|
143
|
+
* Compact renderer for small surfaces like the blog validation modal. Smaller,
|
|
144
|
+
* quieter headings (no oversized doc titles, no permalink anchors), tight
|
|
145
|
+
* paragraph/list rhythm, and subtle code styling so a long report stays
|
|
146
|
+
* readable in a constrained, scrollable panel.
|
|
147
|
+
*/
|
|
148
|
+
private static getBlogValidationRenderer(): Renderer {
|
|
149
|
+
if (this.blogValidationRenderer !== null) {
|
|
150
|
+
return this.blogValidationRenderer;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const renderer: Renderer = new Renderer();
|
|
154
|
+
|
|
155
|
+
renderer.heading = function (text, level) {
|
|
156
|
+
if (level <= 2) {
|
|
157
|
+
return `<h3 class="mt-7 mb-3 pt-6 border-t border-gray-100 first:mt-0 first:pt-0 first:border-t-0 text-[0.7rem] font-semibold uppercase tracking-wider text-emerald-700">${text}</h3>`;
|
|
158
|
+
}
|
|
159
|
+
if (level === 3) {
|
|
160
|
+
return `<h4 class="mt-5 mb-1.5 text-sm font-semibold text-gray-900">${text}</h4>`;
|
|
161
|
+
}
|
|
162
|
+
return `<h5 class="mt-4 mb-1 text-sm font-semibold text-gray-700">${text}</h5>`;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
renderer.paragraph = function (text) {
|
|
166
|
+
return `<p class="my-2.5 text-sm leading-relaxed text-gray-600">${text}</p>`;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
renderer.list = function (body, ordered, start) {
|
|
170
|
+
const tag: string = ordered ? "ol" : "ul";
|
|
171
|
+
const cls: string = ordered
|
|
172
|
+
? "list-decimal pl-5 my-2 space-y-1.5 text-sm text-gray-600 marker:text-gray-400"
|
|
173
|
+
: "list-disc pl-5 my-2 space-y-1.5 text-sm text-gray-600 marker:text-gray-300";
|
|
174
|
+
const startAttr: string =
|
|
175
|
+
ordered && start !== 1 ? ` start="${start}"` : "";
|
|
176
|
+
return `<${tag}${startAttr} class="${cls}">${body}</${tag}>`;
|
|
177
|
+
};
|
|
178
|
+
renderer.listitem = function (text) {
|
|
179
|
+
return `<li class="leading-relaxed pl-1">${text}</li>`;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
renderer.strong = function (text) {
|
|
183
|
+
return `<strong class="font-semibold text-gray-800">${text}</strong>`;
|
|
184
|
+
};
|
|
185
|
+
renderer.em = function (text) {
|
|
186
|
+
return `<em class="italic">${text}</em>`;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
renderer.blockquote = function (quote) {
|
|
190
|
+
return `<blockquote class="my-3 border-s-2 border-emerald-200 ps-3 text-sm text-gray-600">${quote}</blockquote>`;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
renderer.hr = function () {
|
|
194
|
+
return '<hr class="my-5 border-t border-gray-100" />';
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
renderer.codespan = function (code) {
|
|
198
|
+
const escaped: string = Markdown.escapeHtml(code);
|
|
199
|
+
return `<code class="rounded bg-gray-100 px-1.5 py-0.5 text-[0.8125rem] text-gray-700 font-mono break-words">${escaped}</code>`;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
renderer.code = function (code, language) {
|
|
203
|
+
const escaped: string = Markdown.escapeHtml(code);
|
|
204
|
+
return `<pre class="my-3 overflow-x-auto rounded-lg bg-gray-50 border border-gray-100 p-3 text-xs leading-relaxed"><code class="language-${language} font-mono text-gray-700">${escaped}</code></pre>`;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
renderer.table = function (header, body) {
|
|
208
|
+
return `<div class="my-4 overflow-x-auto rounded-lg border border-gray-100"><table class="min-w-full text-sm text-left">${header}${body}</table></div>`;
|
|
209
|
+
};
|
|
210
|
+
renderer.tablerow = function (content) {
|
|
211
|
+
return `<tr class="border-b border-gray-100 last:border-b-0">${content}</tr>`;
|
|
212
|
+
};
|
|
213
|
+
renderer.tablecell = function (content, flags) {
|
|
214
|
+
const tag: string = flags.header ? "th" : "td";
|
|
215
|
+
const align: string = flags.align ? ` text-${flags.align}` : "";
|
|
216
|
+
const headerClass: string = flags.header
|
|
217
|
+
? " font-semibold text-gray-900 bg-gray-50"
|
|
218
|
+
: " text-gray-600";
|
|
219
|
+
return `<${tag} class="px-3 py-2${align}${headerClass}">${content}</${tag}>`;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
renderer.link = function (href, _title, text) {
|
|
223
|
+
if (!href) {
|
|
224
|
+
return text as string;
|
|
225
|
+
}
|
|
226
|
+
const isExternal: boolean = href.startsWith("http");
|
|
227
|
+
const rel: string = isExternal
|
|
228
|
+
? ' target="_blank" rel="noopener noreferrer"'
|
|
229
|
+
: "";
|
|
230
|
+
return `<a href="${href}"${rel} class="text-emerald-700 font-medium underline decoration-emerald-200 underline-offset-2 hover:decoration-emerald-400 break-words">${text}</a>`;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
this.blogValidationRenderer = renderer;
|
|
234
|
+
|
|
235
|
+
return renderer;
|
|
236
|
+
}
|
|
237
|
+
|
|
135
238
|
private static slugify(text: string): string {
|
|
136
239
|
return text
|
|
137
240
|
.toLowerCase()
|
|
@@ -193,8 +193,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
|
|
|
193
193
|
return record;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
private escapeStringLiteral(raw: string): string {
|
|
196
|
+
private escapeStringLiteral(raw: string | undefined | null): string {
|
|
197
197
|
// escape String literal based on https://clickhouse.com/docs/en/sql-reference/syntax#string
|
|
198
|
+
if (raw === undefined || raw === null) {
|
|
199
|
+
return "''";
|
|
200
|
+
}
|
|
198
201
|
return `'${raw.replace(/'|\\/g, "\\$&")}'`;
|
|
199
202
|
}
|
|
200
203
|
|
|
@@ -385,6 +385,84 @@ export default class CodeRepositoryUtil {
|
|
|
385
385
|
return hash.trim();
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Returns the author (name + email) and the list of files changed for every
|
|
390
|
+
* non-merge commit in the repository, newest first. Optionally limited to a
|
|
391
|
+
* pathspec (e.g. "posts"). Used to derive per-directory contributors.
|
|
392
|
+
*
|
|
393
|
+
* Note: a shallow clone (`--depth 1`) only has the latest commit, so this
|
|
394
|
+
* returns a single contributor unless the repo was cloned with history.
|
|
395
|
+
*/
|
|
396
|
+
@CaptureSpan()
|
|
397
|
+
public static async getCommitAuthorsWithFiles(data: {
|
|
398
|
+
repoPath: string;
|
|
399
|
+
path?: string | undefined;
|
|
400
|
+
}): Promise<
|
|
401
|
+
Array<{
|
|
402
|
+
authorName: string;
|
|
403
|
+
authorEmail: string;
|
|
404
|
+
files: Array<string>;
|
|
405
|
+
}>
|
|
406
|
+
> {
|
|
407
|
+
/*
|
|
408
|
+
* ASCII record (0x1e) / unit (0x1f) separators never occur in author names,
|
|
409
|
+
* emails or file paths, so they delimit commits and fields unambiguously.
|
|
410
|
+
*/
|
|
411
|
+
const recordSeparator: string = "\x1e";
|
|
412
|
+
const unitSeparator: string = "\x1f";
|
|
413
|
+
|
|
414
|
+
const args: Array<string> = [
|
|
415
|
+
"log",
|
|
416
|
+
"--no-merges",
|
|
417
|
+
`--pretty=format:${recordSeparator}%an${unitSeparator}%ae`,
|
|
418
|
+
"--name-only",
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
if (data.path) {
|
|
422
|
+
args.push("--", data.path);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const stdout: string = await Execute.executeCommandFile({
|
|
426
|
+
command: "git",
|
|
427
|
+
args,
|
|
428
|
+
cwd: path.resolve(data.repoPath),
|
|
429
|
+
// git log over a full repo can easily exceed the 1 MB default.
|
|
430
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const commits: Array<{
|
|
434
|
+
authorName: string;
|
|
435
|
+
authorEmail: string;
|
|
436
|
+
files: Array<string>;
|
|
437
|
+
}> = [];
|
|
438
|
+
|
|
439
|
+
for (const record of stdout.split(recordSeparator)) {
|
|
440
|
+
if (!record.trim()) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const lines: Array<string> = record.split("\n");
|
|
445
|
+
const header: string = lines.shift() || "";
|
|
446
|
+
const [authorName, authorEmail] = header.split(unitSeparator);
|
|
447
|
+
|
|
448
|
+
const files: Array<string> = lines
|
|
449
|
+
.map((line: string) => {
|
|
450
|
+
return line.trim();
|
|
451
|
+
})
|
|
452
|
+
.filter((line: string) => {
|
|
453
|
+
return line.length > 0;
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
commits.push({
|
|
457
|
+
authorName: (authorName || "").trim(),
|
|
458
|
+
authorEmail: (authorEmail || "").trim(),
|
|
459
|
+
files,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return commits;
|
|
464
|
+
}
|
|
465
|
+
|
|
388
466
|
@CaptureSpan()
|
|
389
467
|
public static async listFilesInDirectory(data: {
|
|
390
468
|
directoryPath: string;
|
package/Server/Utils/Execute.ts
CHANGED
|
@@ -54,6 +54,11 @@ export default class Execute {
|
|
|
54
54
|
command: string;
|
|
55
55
|
args: Array<string>;
|
|
56
56
|
cwd: string;
|
|
57
|
+
/*
|
|
58
|
+
* Override the child_process default stdout/stderr cap (1 MB). Commands that
|
|
59
|
+
* can emit large output (e.g. `git log` over a whole repo) should raise this.
|
|
60
|
+
*/
|
|
61
|
+
maxBuffer?: number | undefined;
|
|
57
62
|
}): Promise<string> {
|
|
58
63
|
return new Promise(
|
|
59
64
|
(
|
|
@@ -65,6 +70,7 @@ export default class Execute {
|
|
|
65
70
|
data.args,
|
|
66
71
|
{
|
|
67
72
|
cwd: data.cwd,
|
|
73
|
+
...(data.maxBuffer ? { maxBuffer: data.maxBuffer } : {}),
|
|
68
74
|
},
|
|
69
75
|
(err: ExecException | null, stdout: string, stderr: string) => {
|
|
70
76
|
if (err) {
|
package/Server/Utils/Logger.ts
CHANGED
|
@@ -8,6 +8,12 @@ import ConfigLogLevel from "../Types/ConfigLogLevel";
|
|
|
8
8
|
|
|
9
9
|
export type LogBody = string | JSONObject | Exception | Error | unknown;
|
|
10
10
|
|
|
11
|
+
export interface RecentLogEntry {
|
|
12
|
+
time: string;
|
|
13
|
+
level: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
export interface LogAttributes {
|
|
12
18
|
userId?: string | undefined;
|
|
13
19
|
projectId?: string | undefined;
|
|
@@ -50,6 +56,56 @@ export function getLogAttributesFromRequest(
|
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
export default class logger {
|
|
59
|
+
/*
|
|
60
|
+
* In-memory ring buffer of the most recent log lines this process emitted.
|
|
61
|
+
* The app writes logs to stdout only (no on-disk file), so this is the one
|
|
62
|
+
* way the master-admin support bundle / health dashboard can surface this
|
|
63
|
+
* instance's own recent logs for debugging — there is no path to the
|
|
64
|
+
* container's stdout or to other containers' logs from inside the process.
|
|
65
|
+
*/
|
|
66
|
+
private static recentLogs: Array<RecentLogEntry> = [];
|
|
67
|
+
private static readonly maxRecentLogs: number = 1000;
|
|
68
|
+
private static readonly recentLogTrimSlack: number = 256;
|
|
69
|
+
private static readonly maxRecentLogMessageLength: number = 4000;
|
|
70
|
+
|
|
71
|
+
private static record(level: string, body: LogBody): void {
|
|
72
|
+
try {
|
|
73
|
+
const message: string = this.serializeLogBody(body);
|
|
74
|
+
|
|
75
|
+
this.recentLogs.push({
|
|
76
|
+
time: new Date().toISOString(),
|
|
77
|
+
level: level,
|
|
78
|
+
message:
|
|
79
|
+
message.length > this.maxRecentLogMessageLength
|
|
80
|
+
? `${message.substring(0, this.maxRecentLogMessageLength)}… (truncated)`
|
|
81
|
+
: message,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
* Trim in batches rather than on every push so this stays O(1) amortised
|
|
86
|
+
* on the logging hot path: only re-slice once we have grown past a soft
|
|
87
|
+
* ceiling above the target size.
|
|
88
|
+
*/
|
|
89
|
+
if (
|
|
90
|
+
this.recentLogs.length >
|
|
91
|
+
this.maxRecentLogs + this.recentLogTrimSlack
|
|
92
|
+
) {
|
|
93
|
+
this.recentLogs = this.recentLogs.slice(-this.maxRecentLogs);
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Never let log capture break the app.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Newest-last snapshot of the recent-log ring buffer (optionally the last N).
|
|
101
|
+
public static getRecentLogs(limit?: number): Array<RecentLogEntry> {
|
|
102
|
+
if (!limit || limit >= this.recentLogs.length) {
|
|
103
|
+
return [...this.recentLogs];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return this.recentLogs.slice(this.recentLogs.length - limit);
|
|
107
|
+
}
|
|
108
|
+
|
|
53
109
|
public static getLogLevel(): ConfigLogLevel {
|
|
54
110
|
if (!LogLevel) {
|
|
55
111
|
return ConfigLogLevel.INFO;
|
|
@@ -98,6 +154,8 @@ export default class logger {
|
|
|
98
154
|
// eslint-disable-next-line no-console
|
|
99
155
|
console.info(message);
|
|
100
156
|
|
|
157
|
+
this.record("INFO", message);
|
|
158
|
+
|
|
101
159
|
this.emit({
|
|
102
160
|
body: message,
|
|
103
161
|
severityNumber: SeverityNumber.INFO,
|
|
@@ -118,6 +176,8 @@ export default class logger {
|
|
|
118
176
|
// eslint-disable-next-line no-console
|
|
119
177
|
console.error(message);
|
|
120
178
|
|
|
179
|
+
this.record("ERROR", message);
|
|
180
|
+
|
|
121
181
|
this.emit({
|
|
122
182
|
body: message,
|
|
123
183
|
severityNumber: SeverityNumber.ERROR,
|
|
@@ -137,6 +197,8 @@ export default class logger {
|
|
|
137
197
|
// eslint-disable-next-line no-console
|
|
138
198
|
console.warn(message);
|
|
139
199
|
|
|
200
|
+
this.record("WARN", message);
|
|
201
|
+
|
|
140
202
|
this.emit({
|
|
141
203
|
body: message,
|
|
142
204
|
severityNumber: SeverityNumber.WARN,
|
|
@@ -152,6 +214,8 @@ export default class logger {
|
|
|
152
214
|
// eslint-disable-next-line no-console
|
|
153
215
|
console.debug(message);
|
|
154
216
|
|
|
217
|
+
this.record("DEBUG", message);
|
|
218
|
+
|
|
155
219
|
this.emit({
|
|
156
220
|
body: message,
|
|
157
221
|
severityNumber: SeverityNumber.DEBUG,
|
|
@@ -203,6 +267,8 @@ export default class logger {
|
|
|
203
267
|
// eslint-disable-next-line no-console
|
|
204
268
|
console.trace(message);
|
|
205
269
|
|
|
270
|
+
this.record("TRACE", message);
|
|
271
|
+
|
|
206
272
|
this.emit({
|
|
207
273
|
body: message,
|
|
208
274
|
severityNumber: SeverityNumber.DEBUG,
|
|
@@ -636,6 +636,81 @@ describe("StatementGenerator", () => {
|
|
|
636
636
|
});
|
|
637
637
|
});
|
|
638
638
|
|
|
639
|
+
describe("toCreateStatement", () => {
|
|
640
|
+
/*
|
|
641
|
+
* Regression test for the escapeStringLiteral undefined/null guard.
|
|
642
|
+
* An ArrayText (Array(String)) column whose value array carried
|
|
643
|
+
* undefined/null members at runtime used to throw
|
|
644
|
+
* "TypeError: Cannot read properties of undefined (reading 'replace')"
|
|
645
|
+
* inside escapeStringLiteral while building the INSERT. The member is
|
|
646
|
+
* now rendered as the empty string literal '' so the statement still
|
|
647
|
+
* serializes to a valid ClickHouse Array(String).
|
|
648
|
+
*/
|
|
649
|
+
class ArrayTextModel extends AnalyticsBaseModel {
|
|
650
|
+
public constructor() {
|
|
651
|
+
super({
|
|
652
|
+
tableName: "<array-create-table>",
|
|
653
|
+
singularName: "<singular>",
|
|
654
|
+
pluralName: "<plural>",
|
|
655
|
+
tableColumns: [
|
|
656
|
+
new AnalyticsTableColumn({
|
|
657
|
+
key: "_id",
|
|
658
|
+
title: "<title>",
|
|
659
|
+
description: "<description>",
|
|
660
|
+
required: true,
|
|
661
|
+
type: TableColumnType.ObjectID,
|
|
662
|
+
}),
|
|
663
|
+
new AnalyticsTableColumn({
|
|
664
|
+
key: "entityKeys",
|
|
665
|
+
title: "<title>",
|
|
666
|
+
description: "<description>",
|
|
667
|
+
required: true,
|
|
668
|
+
defaultValue: [],
|
|
669
|
+
type: TableColumnType.ArrayText,
|
|
670
|
+
}),
|
|
671
|
+
],
|
|
672
|
+
crudApiPath: new Route("route"),
|
|
673
|
+
primaryKeys: ["_id"],
|
|
674
|
+
sortKeys: ["_id"],
|
|
675
|
+
partitionKey: "_id",
|
|
676
|
+
tableEngine: AnalyticsTableEngine.MergeTree,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
let arrayTextGenerator: StatementGenerator<ArrayTextModel>;
|
|
682
|
+
beforeEach(() => {
|
|
683
|
+
arrayTextGenerator = new StatementGenerator<ArrayTextModel>({
|
|
684
|
+
modelType: ArrayTextModel,
|
|
685
|
+
database: ClickhouseAppInstance,
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("renders undefined/null ArrayText elements as '' instead of throwing", () => {
|
|
690
|
+
const item: ArrayTextModel = new ArrayTextModel();
|
|
691
|
+
item.setColumnValue("_id", "210dac24142f1baa");
|
|
692
|
+
/*
|
|
693
|
+
* Simulate runtime data where the Array(String) column carries
|
|
694
|
+
* undefined/null members. The type system models the column as
|
|
695
|
+
* string[], hence the cast — this is exactly what crashed before
|
|
696
|
+
* the guard was added.
|
|
697
|
+
*/
|
|
698
|
+
item.setColumnValue("entityKeys", [
|
|
699
|
+
"alpha",
|
|
700
|
+
undefined,
|
|
701
|
+
null,
|
|
702
|
+
"beta",
|
|
703
|
+
] as unknown as Array<string>);
|
|
704
|
+
|
|
705
|
+
let statement: string = "";
|
|
706
|
+
expect(() => {
|
|
707
|
+
statement = arrayTextGenerator.toCreateStatement({ item: [item] });
|
|
708
|
+
}).not.toThrow();
|
|
709
|
+
|
|
710
|
+
expect(statement).toContain("['alpha', '', '', 'beta']");
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
639
714
|
describe("toSelectStatement", () => {
|
|
640
715
|
test("should return the contents of a SELECT statement", () => {
|
|
641
716
|
const { statement, columns } = generator.toSelectStatement({
|