@oneuptime/common 10.0.67 → 10.0.69

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 (88) hide show
  1. package/Models/AnalyticsModels/AuditLog.ts +370 -0
  2. package/Models/DatabaseModels/Alert.ts +2 -0
  3. package/Models/DatabaseModels/ApiKey.ts +2 -0
  4. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +3 -0
  5. package/Models/DatabaseModels/Incident.ts +2 -0
  6. package/Models/DatabaseModels/KubernetesResource.ts +19 -0
  7. package/Models/DatabaseModels/Label.ts +2 -0
  8. package/Models/DatabaseModels/Monitor.ts +2 -0
  9. package/Models/DatabaseModels/OnCallDutyPolicy.ts +2 -0
  10. package/Models/DatabaseModels/Project.ts +80 -0
  11. package/Models/DatabaseModels/ScheduledMaintenance.ts +2 -0
  12. package/Models/DatabaseModels/StatusPage.ts +2 -0
  13. package/Models/DatabaseModels/Team.ts +2 -0
  14. package/Models/DatabaseModels/UserTelegram.ts +1 -1
  15. package/Server/API/KubernetesResourceAPI.ts +2 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1776801030808-MigrationName.ts +35 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/1776865086264-MigrationName.ts +14 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  19. package/Server/Services/AuditLogService.ts +574 -0
  20. package/Server/Services/DatabaseService.ts +103 -0
  21. package/Server/Services/Index.ts +2 -0
  22. package/Server/Services/KubernetesResourceService.ts +300 -8
  23. package/Server/Utils/VM/VMRunner.ts +39 -22
  24. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +1 -0
  25. package/Types/AuditLog/AuditLogAction.ts +7 -0
  26. package/Types/BaseDatabase/EnableAuditLogOn.ts +5 -0
  27. package/Types/Database/EnableAuditLog.ts +18 -0
  28. package/Types/IsolatedVM/ReturnResult.ts +6 -0
  29. package/Types/Kubernetes/KubernetesInventoryExtractor.ts +15 -1
  30. package/Types/Permission.ts +13 -0
  31. package/build/dist/Models/AnalyticsModels/AuditLog.js +337 -0
  32. package/build/dist/Models/AnalyticsModels/AuditLog.js.map +1 -0
  33. package/build/dist/Models/DatabaseModels/Alert.js +2 -0
  34. package/build/dist/Models/DatabaseModels/Alert.js.map +1 -1
  35. package/build/dist/Models/DatabaseModels/ApiKey.js +2 -0
  36. package/build/dist/Models/DatabaseModels/ApiKey.js.map +1 -1
  37. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  38. package/build/dist/Models/DatabaseModels/Incident.js +2 -0
  39. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  40. package/build/dist/Models/DatabaseModels/KubernetesResource.js +20 -0
  41. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  42. package/build/dist/Models/DatabaseModels/Label.js +2 -0
  43. package/build/dist/Models/DatabaseModels/Label.js.map +1 -1
  44. package/build/dist/Models/DatabaseModels/Monitor.js +2 -0
  45. package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
  46. package/build/dist/Models/DatabaseModels/OnCallDutyPolicy.js +2 -0
  47. package/build/dist/Models/DatabaseModels/OnCallDutyPolicy.js.map +1 -1
  48. package/build/dist/Models/DatabaseModels/Project.js +82 -0
  49. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  50. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +2 -0
  51. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  52. package/build/dist/Models/DatabaseModels/StatusPage.js +2 -0
  53. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  54. package/build/dist/Models/DatabaseModels/Team.js +2 -0
  55. package/build/dist/Models/DatabaseModels/Team.js.map +1 -1
  56. package/build/dist/Models/DatabaseModels/UserTelegram.js +1 -1
  57. package/build/dist/Models/DatabaseModels/UserTelegram.js.map +1 -1
  58. package/build/dist/Server/API/KubernetesResourceAPI.js +2 -0
  59. package/build/dist/Server/API/KubernetesResourceAPI.js.map +1 -1
  60. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776801030808-MigrationName.js +18 -0
  61. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776801030808-MigrationName.js.map +1 -0
  62. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776865086264-MigrationName.js +12 -0
  63. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776865086264-MigrationName.js.map +1 -0
  64. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  65. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  66. package/build/dist/Server/Services/AuditLogService.js +402 -0
  67. package/build/dist/Server/Services/AuditLogService.js.map +1 -0
  68. package/build/dist/Server/Services/DatabaseService.js +79 -8
  69. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  70. package/build/dist/Server/Services/Index.js +2 -0
  71. package/build/dist/Server/Services/Index.js.map +1 -1
  72. package/build/dist/Server/Services/KubernetesResourceService.js +202 -8
  73. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  74. package/build/dist/Server/Utils/VM/VMRunner.js +33 -19
  75. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  76. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +1 -0
  77. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  78. package/build/dist/Types/AuditLog/AuditLogAction.js +8 -0
  79. package/build/dist/Types/AuditLog/AuditLogAction.js.map +1 -0
  80. package/build/dist/Types/BaseDatabase/EnableAuditLogOn.js +2 -0
  81. package/build/dist/Types/BaseDatabase/EnableAuditLogOn.js.map +1 -0
  82. package/build/dist/Types/Database/EnableAuditLog.js +15 -0
  83. package/build/dist/Types/Database/EnableAuditLog.js.map +1 -0
  84. package/build/dist/Types/Kubernetes/KubernetesInventoryExtractor.js +7 -1
  85. package/build/dist/Types/Kubernetes/KubernetesInventoryExtractor.js.map +1 -1
  86. package/build/dist/Types/Permission.js +11 -0
  87. package/build/dist/Types/Permission.js.map +1 -1
  88. package/package.json +1 -1
@@ -772,6 +772,31 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
772
772
  );
773
773
  }
774
774
 
775
+ if (
776
+ !createBy.props.ignoreHooks &&
777
+ this.getModel().enableAuditLogOn?.create
778
+ ) {
779
+ /*
780
+ * Lazy require to avoid circular dependency between DatabaseService and
781
+ * AuditLogService (which depends on ProjectService/UserService, both of
782
+ * which extend DatabaseService). A top-level import leaves
783
+ * DatabaseService undefined at class-extension time for subclasses.
784
+ */
785
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
786
+ const auditLogService: {
787
+ recordCreate: (data: {
788
+ model: TBaseModel;
789
+ createdItem: TBaseModel;
790
+ props: DatabaseCommonInteractionProps;
791
+ }) => Promise<void>;
792
+ } = require("./AuditLogService").default;
793
+ await auditLogService.recordCreate({
794
+ model: this.getModel(),
795
+ createdItem: createBy.data,
796
+ props: createBy.props,
797
+ });
798
+ }
799
+
775
800
  return createBy.data;
776
801
  } catch (error) {
777
802
  await this.onCreateError(error as Exception);
@@ -1117,6 +1142,18 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1117
1142
  (select as any)[this.getModel().getTenantColumn() as string] = true;
1118
1143
  }
1119
1144
 
1145
+ /*
1146
+ * If audit logging on delete is enabled, fetch all scalar columns so we
1147
+ * can record a full snapshot of the record before it is deleted.
1148
+ */
1149
+ if (this.getModel().enableAuditLogOn?.delete) {
1150
+ const allColumns: Array<string> =
1151
+ this.getModel().getTableColumns().columns;
1152
+ for (const columnName of allColumns) {
1153
+ (select as any)[columnName] = true;
1154
+ }
1155
+ }
1156
+
1120
1157
  const items: Array<TBaseModel> = await this._findBy({
1121
1158
  query: beforeDeleteBy.query,
1122
1159
  skip: beforeDeleteBy.skip.toNumber(),
@@ -1199,6 +1236,28 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1199
1236
  );
1200
1237
  }
1201
1238
 
1239
+ if (this.getModel().enableAuditLogOn?.delete && items.length > 0) {
1240
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1241
+ const auditLogService: {
1242
+ recordDelete: (args: {
1243
+ model: TBaseModel;
1244
+ deletedItem: TBaseModel;
1245
+ itemId: ObjectID;
1246
+ props: DatabaseCommonInteractionProps;
1247
+ }) => Promise<void>;
1248
+ } = require("./AuditLogService").default;
1249
+ for (const item of items) {
1250
+ if (item.id) {
1251
+ await auditLogService.recordDelete({
1252
+ model: this.getModel(),
1253
+ deletedItem: item,
1254
+ itemId: item.id,
1255
+ props: deleteBy.props,
1256
+ });
1257
+ }
1258
+ }
1259
+ }
1260
+
1202
1261
  return numberOfDocsAffected;
1203
1262
  } catch (error) {
1204
1263
  await this.onDeleteError(error as Exception);
@@ -1533,6 +1592,26 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1533
1592
  true;
1534
1593
  }
1535
1594
 
1595
+ /*
1596
+ * When audit logging on update is enabled, ensure the resource's display
1597
+ * name is loaded on the `before` snapshot so the audit entry records the
1598
+ * human-readable resource name even when the update doesn't touch it.
1599
+ */
1600
+ if (this.getModel().enableAuditLogOn?.update) {
1601
+ const nameCandidates: ReadonlyArray<string> = [
1602
+ "name",
1603
+ "title",
1604
+ "displayName",
1605
+ ];
1606
+ const modelColumns: Array<string> =
1607
+ this.getModel().getTableColumns().columns;
1608
+ for (const candidate of nameCandidates) {
1609
+ if (modelColumns.includes(candidate)) {
1610
+ (selectColumns as any)[candidate] = true;
1611
+ }
1612
+ }
1613
+ }
1614
+
1536
1615
  const items: Array<TBaseModel> = await this._findBy({
1537
1616
  query: beforeUpdateBy.query,
1538
1617
  skip: updateBy.skip.toNumber(),
@@ -1582,6 +1661,30 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1582
1661
  );
1583
1662
  }
1584
1663
  }
1664
+
1665
+ if (
1666
+ this.getModel().enableAuditLogOn?.update &&
1667
+ !this.hasSameValues({ item, updatedItem }) &&
1668
+ item.id
1669
+ ) {
1670
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1671
+ const auditLogService: {
1672
+ recordUpdate: (args: {
1673
+ model: TBaseModel;
1674
+ before: TBaseModel;
1675
+ updatedFields: JSONObject;
1676
+ itemId: ObjectID;
1677
+ props: DatabaseCommonInteractionProps;
1678
+ }) => Promise<void>;
1679
+ } = require("./AuditLogService").default;
1680
+ await auditLogService.recordUpdate({
1681
+ model: this.getModel(),
1682
+ before: item,
1683
+ updatedFields: data as JSONObject,
1684
+ itemId: item.id,
1685
+ props: updateBy.props,
1686
+ });
1687
+ }
1585
1688
  }
1586
1689
 
1587
1690
  /*
@@ -36,6 +36,7 @@ import LabelService from "./LabelService";
36
36
  import KubernetesClusterService from "./KubernetesClusterService";
37
37
  import DockerHostService from "./DockerHostService";
38
38
  import LlmProviderService from "./LlmProviderService";
39
+ import AuditLogService from "./AuditLogService";
39
40
  import LogService from "./LogService";
40
41
  import MailService from "./MailService";
41
42
  import MetricService from "./MetricService";
@@ -447,6 +448,7 @@ export const AnalyticsServices: Array<
447
448
  MonitorLogService,
448
449
  ProfileService,
449
450
  ProfileSampleService,
451
+ AuditLogService,
450
452
  ];
451
453
 
452
454
  export default services;
@@ -22,10 +22,28 @@ import logger from "../Utils/Logger";
22
22
 
23
23
  export type { ParsedKubernetesResource };
24
24
 
25
+ export interface DegradedPod {
26
+ name: string;
27
+ namespace: string;
28
+ phase: string;
29
+ reason: string;
30
+ message: string;
31
+ }
32
+
33
+ export interface DegradedNode {
34
+ name: string;
35
+ isReady: boolean;
36
+ hasMemoryPressure: boolean;
37
+ hasDiskPressure: boolean;
38
+ hasPidPressure: boolean;
39
+ reason: string;
40
+ message: string;
41
+ }
42
+
25
43
  export interface InventorySummary {
26
44
  countsByKind: Record<string, number>;
27
45
  /*
28
- * Sum of `jsonb_array_length(spec->'containers')` across all pods in
46
+ * Sum of the denormalized containerCount column across all pods in
29
47
  * the cluster. Containers aren't a top-level kind in the inventory,
30
48
  * so we derive the total server-side so the sidebar badge and the
31
49
  * Containers page agree.
@@ -47,6 +65,201 @@ export interface InventorySummary {
47
65
  diskPressure: number;
48
66
  pidPressure: number;
49
67
  };
68
+ /*
69
+ * Top offenders that explain a Degraded/Unhealthy cluster state. Capped
70
+ * so a pathological cluster can't blow up the overview payload; the
71
+ * dedicated Pods/Nodes pages are the source of truth for the full list.
72
+ */
73
+ degradedPods: Array<DegradedPod>;
74
+ degradedNodes: Array<DegradedNode>;
75
+ }
76
+
77
+ const DEGRADED_SAMPLE_LIMIT: number = 20;
78
+
79
+ /*
80
+ * Pull the first meaningful reason/message off a pod's status block.
81
+ * KubernetesInventoryExtractor stores containerStatuses as an array of
82
+ * { name, ready, state: "running"|"waiting"|"terminated", reason, message, ... }.
83
+ * A waiting container with a reason (ImagePullBackOff, CrashLoopBackOff,
84
+ * CreateContainerConfigError, ...) is exactly what the user needs to see,
85
+ * so we surface that first. We fall back to terminated reasons (OOMKilled,
86
+ * Error, ContainerCannotRun) and then to status-level conditions.
87
+ */
88
+ function buildDegradedPod(row: {
89
+ name: string;
90
+ namespaceKey: string;
91
+ phase: string | null;
92
+ status: unknown;
93
+ }): DegradedPod {
94
+ const status: Record<string, unknown> =
95
+ row.status && typeof row.status === "object"
96
+ ? (row.status as Record<string, unknown>)
97
+ : {};
98
+
99
+ let reason: string = "";
100
+ let message: string = "";
101
+
102
+ const containerStatuses: Array<Record<string, unknown>> = Array.isArray(
103
+ status["containerStatuses"],
104
+ )
105
+ ? (status["containerStatuses"] as Array<Record<string, unknown>>)
106
+ : [];
107
+ const initContainerStatuses: Array<Record<string, unknown>> = Array.isArray(
108
+ status["initContainerStatuses"],
109
+ )
110
+ ? (status["initContainerStatuses"] as Array<Record<string, unknown>>)
111
+ : [];
112
+
113
+ const scanForReason: (
114
+ list: Array<Record<string, unknown>>,
115
+ targetState: string,
116
+ ) => { reason: string; message: string } | null = (list, targetState) => {
117
+ for (const cs of list) {
118
+ if (cs["state"] !== targetState) {
119
+ continue;
120
+ }
121
+ const r: unknown = cs["reason"];
122
+ if (typeof r === "string" && r) {
123
+ const m: unknown = cs["message"];
124
+ return {
125
+ reason: r,
126
+ message: typeof m === "string" ? m : "",
127
+ };
128
+ }
129
+ }
130
+ return null;
131
+ };
132
+
133
+ const waitingHit: { reason: string; message: string } | null =
134
+ scanForReason(containerStatuses, "waiting") ||
135
+ scanForReason(initContainerStatuses, "waiting");
136
+ const terminatedHit: { reason: string; message: string } | null = waitingHit
137
+ ? null
138
+ : scanForReason(containerStatuses, "terminated") ||
139
+ scanForReason(initContainerStatuses, "terminated");
140
+ const hit: { reason: string; message: string } | null =
141
+ waitingHit || terminatedHit;
142
+
143
+ if (hit) {
144
+ reason = hit.reason;
145
+ message = hit.message;
146
+ } else {
147
+ // Fall back to the pod-level reason/message fields set by the scheduler
148
+ // (e.g. "Unschedulable" with "0/3 nodes are available: ...").
149
+ const topReason: unknown = status["reason"];
150
+ const topMessage: unknown = status["message"];
151
+ if (typeof topReason === "string") {
152
+ reason = topReason;
153
+ }
154
+ if (typeof topMessage === "string") {
155
+ message = topMessage;
156
+ }
157
+
158
+ // If still nothing, pull from the first non-True condition.
159
+ if (!reason) {
160
+ const conditions: Array<Record<string, unknown>> = Array.isArray(
161
+ status["conditions"],
162
+ )
163
+ ? (status["conditions"] as Array<Record<string, unknown>>)
164
+ : [];
165
+ for (const cond of conditions) {
166
+ if (cond["status"] !== "True") {
167
+ const r: unknown = cond["reason"];
168
+ const m: unknown = cond["message"];
169
+ if (typeof r === "string" && r) {
170
+ reason = r;
171
+ message = typeof m === "string" ? m : "";
172
+ break;
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ return {
180
+ name: row.name,
181
+ namespace: row.namespaceKey || "",
182
+ phase: row.phase || "Unknown",
183
+ reason,
184
+ message,
185
+ };
186
+ }
187
+
188
+ /*
189
+ * For a Node: if isReady is false, the "Ready" condition carries the real
190
+ * story (e.g. "KubeletNotReady: PLEG is not healthy"). If only pressure
191
+ * flags are tripped, pick the tripped condition's reason/message.
192
+ */
193
+ function buildDegradedNode(row: {
194
+ name: string;
195
+ isReady: boolean | null;
196
+ hasMemoryPressure: boolean | null;
197
+ hasDiskPressure: boolean | null;
198
+ hasPidPressure: boolean | null;
199
+ status: unknown;
200
+ }): DegradedNode {
201
+ const status: Record<string, unknown> =
202
+ row.status && typeof row.status === "object"
203
+ ? (row.status as Record<string, unknown>)
204
+ : {};
205
+
206
+ const conditions: Array<Record<string, unknown>> = Array.isArray(
207
+ status["conditions"],
208
+ )
209
+ ? (status["conditions"] as Array<Record<string, unknown>>)
210
+ : [];
211
+
212
+ const findCondition: (
213
+ predicate: (c: Record<string, unknown>) => boolean,
214
+ ) => Record<string, unknown> | null = (predicate) => {
215
+ for (const c of conditions) {
216
+ if (predicate(c)) {
217
+ return c;
218
+ }
219
+ }
220
+ return null;
221
+ };
222
+
223
+ let picked: Record<string, unknown> | null = null;
224
+ if (row.isReady === false) {
225
+ picked = findCondition((c: Record<string, unknown>) => {
226
+ return c["type"] === "Ready" && c["status"] !== "True";
227
+ });
228
+ }
229
+ if (!picked && row.hasMemoryPressure === true) {
230
+ picked = findCondition((c: Record<string, unknown>) => {
231
+ return c["type"] === "MemoryPressure" && c["status"] === "True";
232
+ });
233
+ }
234
+ if (!picked && row.hasDiskPressure === true) {
235
+ picked = findCondition((c: Record<string, unknown>) => {
236
+ return c["type"] === "DiskPressure" && c["status"] === "True";
237
+ });
238
+ }
239
+ if (!picked && row.hasPidPressure === true) {
240
+ picked = findCondition((c: Record<string, unknown>) => {
241
+ return c["type"] === "PIDPressure" && c["status"] === "True";
242
+ });
243
+ }
244
+
245
+ const reason: string =
246
+ picked && typeof picked["reason"] === "string"
247
+ ? (picked["reason"] as string)
248
+ : "";
249
+ const message: string =
250
+ picked && typeof picked["message"] === "string"
251
+ ? (picked["message"] as string)
252
+ : "";
253
+
254
+ return {
255
+ name: row.name,
256
+ isReady: row.isReady === true,
257
+ hasMemoryPressure: row.hasMemoryPressure === true,
258
+ hasDiskPressure: row.hasDiskPressure === true,
259
+ hasPidPressure: row.hasPidPressure === true,
260
+ reason,
261
+ message,
262
+ };
50
263
  }
51
264
 
52
265
  const UPSERT_BATCH_SIZE: number = 500;
@@ -72,6 +285,7 @@ const UPSERT_COLUMNS: Array<keyof ParsedKubernetesResource | string> = [
72
285
  "annotations",
73
286
  "ownerReferences",
74
287
  "spec",
288
+ "containerCount",
75
289
  "status",
76
290
  "lastSeenAt",
77
291
  "resourceCreationTimestamp",
@@ -133,6 +347,7 @@ export class Service extends DatabaseService<Model> {
133
347
  r.annotations ? JSON.stringify(r.annotations) : null,
134
348
  r.ownerReferences ? JSON.stringify(r.ownerReferences) : null,
135
349
  r.spec ? JSON.stringify(r.spec) : null,
350
+ r.containerCount,
136
351
  r.status ? JSON.stringify(r.status) : null,
137
352
  r.lastSeenAt,
138
353
  r.resourceCreationTimestamp,
@@ -145,7 +360,7 @@ export class Service extends DatabaseService<Model> {
145
360
  "projectId", "kubernetesClusterId", "kind", "namespaceKey", "name",
146
361
  "uid", "phase", "isReady",
147
362
  "hasMemoryPressure", "hasDiskPressure", "hasPidPressure",
148
- "labels", "annotations", "ownerReferences", "spec", "status",
363
+ "labels", "annotations", "ownerReferences", "spec", "containerCount", "status",
149
364
  "lastSeenAt", "resourceCreationTimestamp", "version"
150
365
  )
151
366
  VALUES ${valueFragments.join(", ")}
@@ -161,6 +376,7 @@ export class Service extends DatabaseService<Model> {
161
376
  "annotations" = EXCLUDED."annotations",
162
377
  "ownerReferences" = EXCLUDED."ownerReferences",
163
378
  "spec" = EXCLUDED."spec",
379
+ "containerCount" = EXCLUDED."containerCount",
164
380
  "status" = EXCLUDED."status",
165
381
  "lastSeenAt" = EXCLUDED."lastSeenAt",
166
382
  "resourceCreationTimestamp" = EXCLUDED."resourceCreationTimestamp",
@@ -218,7 +434,14 @@ export class Service extends DatabaseService<Model> {
218
434
  const manager: ReturnType<Service["getRepository"]>["manager"] =
219
435
  this.getRepository().manager;
220
436
 
221
- const [kindRows, podRows, nodeRows, containerRows]: [
437
+ const [
438
+ kindRows,
439
+ podRows,
440
+ nodeRows,
441
+ containerRows,
442
+ degradedPodRows,
443
+ degradedNodeRows,
444
+ ]: [
222
445
  Array<{ kind: string; count: string }>,
223
446
  Array<{ phase: string | null; count: string }>,
224
447
  Array<{
@@ -229,6 +452,20 @@ export class Service extends DatabaseService<Model> {
229
452
  pidPressure: string;
230
453
  }>,
231
454
  Array<{ total: string }>,
455
+ Array<{
456
+ name: string;
457
+ namespaceKey: string;
458
+ phase: string | null;
459
+ status: unknown;
460
+ }>,
461
+ Array<{
462
+ name: string;
463
+ isReady: boolean | null;
464
+ hasMemoryPressure: boolean | null;
465
+ hasDiskPressure: boolean | null;
466
+ hasPidPressure: boolean | null;
467
+ status: unknown;
468
+ }>,
232
469
  ] = await Promise.all([
233
470
  manager.query(
234
471
  `SELECT "kind", COUNT(*)::text AS count
@@ -256,15 +493,61 @@ export class Service extends DatabaseService<Model> {
256
493
  [data.projectId.toString(), data.kubernetesClusterId.toString()],
257
494
  ),
258
495
  manager.query(
259
- `SELECT COALESCE(SUM(
260
- CASE WHEN jsonb_typeof("spec"->'containers') = 'array'
261
- THEN jsonb_array_length("spec"->'containers')
262
- ELSE 0 END
263
- ), 0)::text AS total
496
+ /*
497
+ * containerCount is cached on the row during ingest
498
+ * (KubernetesInventoryExtractor sets it from
499
+ * spec.containers.length), so this is a plain int sum instead
500
+ * of a JSONB scan. Rows written before that ingest change may
501
+ * have NULL; SUM treats those as 0, which matches the old
502
+ * behavior.
503
+ */
504
+ `SELECT COALESCE(SUM("containerCount"), 0)::text AS total
264
505
  FROM "KubernetesResource"
265
506
  WHERE "projectId" = $1 AND "kubernetesClusterId" = $2 AND "kind" = 'Pod' AND "deletedAt" IS NULL`,
266
507
  [data.projectId.toString(), data.kubernetesClusterId.toString()],
267
508
  ),
509
+ /*
510
+ * Top-N offenders powering the "Why is this cluster degraded?" card.
511
+ * Failed first (hardest outage), then Pending, then Unknown, so the
512
+ * user sees the worst stuff first without having to sort client-side.
513
+ */
514
+ manager.query(
515
+ `SELECT "name", "namespaceKey", "phase", "status"
516
+ FROM "KubernetesResource"
517
+ WHERE "projectId" = $1
518
+ AND "kubernetesClusterId" = $2
519
+ AND "kind" = 'Pod'
520
+ AND "deletedAt" IS NULL
521
+ AND ("phase" IS NULL OR "phase" NOT IN ('Running', 'Succeeded'))
522
+ ORDER BY
523
+ CASE "phase"
524
+ WHEN 'Failed' THEN 0
525
+ WHEN 'Pending' THEN 1
526
+ ELSE 2
527
+ END,
528
+ "lastSeenAt" DESC
529
+ LIMIT ${DEGRADED_SAMPLE_LIMIT}`,
530
+ [data.projectId.toString(), data.kubernetesClusterId.toString()],
531
+ ),
532
+ manager.query(
533
+ `SELECT "name", "isReady", "hasMemoryPressure", "hasDiskPressure", "hasPidPressure", "status"
534
+ FROM "KubernetesResource"
535
+ WHERE "projectId" = $1
536
+ AND "kubernetesClusterId" = $2
537
+ AND "kind" = 'Node'
538
+ AND "deletedAt" IS NULL
539
+ AND (
540
+ "isReady" IS FALSE
541
+ OR "hasMemoryPressure" IS TRUE
542
+ OR "hasDiskPressure" IS TRUE
543
+ OR "hasPidPressure" IS TRUE
544
+ )
545
+ ORDER BY
546
+ CASE WHEN "isReady" IS FALSE THEN 0 ELSE 1 END,
547
+ "lastSeenAt" DESC
548
+ LIMIT ${DEGRADED_SAMPLE_LIMIT}`,
549
+ [data.projectId.toString(), data.kubernetesClusterId.toString()],
550
+ ),
268
551
  ]);
269
552
 
270
553
  const countsByKind: Record<string, number> = {};
@@ -308,6 +591,13 @@ export class Service extends DatabaseService<Model> {
308
591
  const containerCount: number =
309
592
  parseInt(containerRows[0]?.total || "0", 10) || 0;
310
593
 
594
+ const degradedPods: Array<DegradedPod> = degradedPodRows.map((row) => {
595
+ return buildDegradedPod(row);
596
+ });
597
+ const degradedNodes: Array<DegradedNode> = degradedNodeRows.map((row) => {
598
+ return buildDegradedNode(row);
599
+ });
600
+
311
601
  return {
312
602
  countsByKind,
313
603
  containerCount,
@@ -321,6 +611,8 @@ export class Service extends DatabaseService<Model> {
321
611
  diskPressure: parseInt(nodeRow?.diskPressure || "0", 10) || 0,
322
612
  pidPressure: parseInt(nodeRow?.pidPressure || "0", 10) || 0,
323
613
  },
614
+ degradedPods,
615
+ degradedNodes,
324
616
  };
325
617
  }
326
618
 
@@ -224,7 +224,7 @@ function createSandboxProxy(
224
224
  * Recursively unwraps sandbox proxies in a return value so the host code
225
225
  * receives original objects (e.g. Buffers that pass `instanceof` checks).
226
226
  */
227
- function deepUnwrapProxies(
227
+ export function deepUnwrapProxies(
228
228
  value: unknown,
229
229
  visited?: WeakSet<GenericObject>,
230
230
  ): unknown {
@@ -469,33 +469,50 @@ export default class VMRunner {
469
469
  })()`;
470
470
 
471
471
  try {
472
- /*
473
- * vm timeout only covers synchronous CPU time, so wrap with
474
- * Promise.race to also cover async operations (network, timers, etc.)
475
- */
476
- const vmPromise: Promise<unknown> = vm.runInContext(script, sandbox, {
477
- timeout: timeout,
478
- });
479
-
480
- const overallTimeout: Promise<never> = new Promise(
481
- (_resolve: (value: never) => void, reject: (reason: Error) => void) => {
482
- const handle: NodeJS.Timeout = global.setTimeout(() => {
483
- reject(new Error("Script execution timed out"));
484
- }, timeout + 5000);
485
- // Don't let this timer keep the process alive
486
- handle.unref();
487
- },
488
- );
472
+ let returnVal: unknown;
473
+ let scriptError: Error | undefined;
474
+
475
+ try {
476
+ /*
477
+ * vm timeout only covers synchronous CPU time, so wrap with
478
+ * Promise.race to also cover async operations (network, timers, etc.)
479
+ */
480
+ const vmPromise: Promise<unknown> = vm.runInContext(script, sandbox, {
481
+ timeout: timeout,
482
+ });
489
483
 
490
- const returnVal: unknown = await Promise.race([
491
- vmPromise,
492
- overallTimeout,
493
- ]);
484
+ const overallTimeout: Promise<never> = new Promise(
485
+ (
486
+ _resolve: (value: never) => void,
487
+ reject: (reason: Error) => void,
488
+ ) => {
489
+ const handle: NodeJS.Timeout = global.setTimeout(() => {
490
+ reject(new Error("Script execution timed out"));
491
+ }, timeout + 5000);
492
+ // Don't let this timer keep the process alive
493
+ handle.unref();
494
+ },
495
+ );
496
+
497
+ returnVal = await Promise.race([vmPromise, overallTimeout]);
498
+ } catch (err: unknown) {
499
+ /*
500
+ * Capture user-thrown errors (including timeouts) so the caller can
501
+ * still access side-channel data collected before the throw — e.g.
502
+ * screenshots assigned to a host-realm object passed via `context`.
503
+ * Rethrowing here would discard those partial results.
504
+ */
505
+ scriptError =
506
+ err instanceof Error
507
+ ? err
508
+ : new Error(typeof err === "string" ? err : String(err));
509
+ }
494
510
 
495
511
  return {
496
512
  returnValue: deepUnwrapProxies(returnVal),
497
513
  logMessages,
498
514
  capturedMetrics,
515
+ scriptError,
499
516
  };
500
517
  } finally {
501
518
  // Clean up any lingering timers to prevent resource leaks
@@ -6,6 +6,7 @@ enum AnalyticsTableName {
6
6
  MonitorLog = "MonitorLogV2",
7
7
  Profile = "ProfileItemV2",
8
8
  ProfileSample = "ProfileSampleItemV2",
9
+ AuditLog = "AuditLogV1",
9
10
  }
10
11
 
11
12
  export default AnalyticsTableName;
@@ -0,0 +1,7 @@
1
+ enum AuditLogAction {
2
+ Create = "Create",
3
+ Update = "Update",
4
+ Delete = "Delete",
5
+ }
6
+
7
+ export default AuditLogAction;
@@ -0,0 +1,5 @@
1
+ export default interface EnableAuditLogOn {
2
+ create?: boolean | undefined;
3
+ update?: boolean | undefined;
4
+ delete?: boolean | undefined;
5
+ }
@@ -0,0 +1,18 @@
1
+ import EnableAuditLogOn from "../BaseDatabase/EnableAuditLogOn";
2
+ import GenericFunction from "../GenericFunction";
3
+
4
+ export default (
5
+ enableAuditLogOn: EnableAuditLogOn = {
6
+ create: true,
7
+ update: true,
8
+ delete: true,
9
+ },
10
+ ) => {
11
+ return (ctr: GenericFunction) => {
12
+ ctr.prototype.enableAuditLogOn = {
13
+ create: enableAuditLogOn.create ?? true,
14
+ update: enableAuditLogOn.update ?? true,
15
+ delete: enableAuditLogOn.delete ?? true,
16
+ };
17
+ };
18
+ };
@@ -4,4 +4,10 @@ export default interface ReturnResult {
4
4
  returnValue: any;
5
5
  logMessages: string[];
6
6
  capturedMetrics: CapturedMetric[];
7
+ /**
8
+ * Populated when user-supplied code threw (or timed out). The runner still
9
+ * returns collected side-channel data (logs, metrics, and any host-realm
10
+ * context objects the caller passed in) so partial state survives the throw.
11
+ */
12
+ scriptError?: Error | undefined;
7
13
  }