@oneuptime/common 11.7.2 → 11.7.3
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/AnalyticsModels/KubernetesCostAllocation.ts +103 -0
- package/Models/DatabaseModels/TableView.ts +40 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.ts +13 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Tests/Models/AnalyticsModels/KubernetesCostAllocation.test.ts +16 -0
- package/Tests/Types/JSONFunctions.test.ts +103 -1
- package/Tests/UI/Components/ModelTable/BaseModelTableColumnCustomization.test.tsx +679 -0
- package/Tests/UI/Components/ModelTable/ColumnCustomizationModal.test.tsx +572 -0
- package/Tests/UI/Components/ModelTable/ColumnPreference.test.ts +1678 -0
- package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +1094 -0
- package/Tests/Utils/UserPreferences.test.ts +563 -0
- package/Types/JSONFunctions.ts +7 -2
- package/Types/Kubernetes/KubernetesCostIngest.ts +37 -1
- package/UI/Components/MasterPage/MasterPage.tsx +65 -0
- package/UI/Components/ModelTable/BaseModelTable.tsx +387 -4
- package/UI/Components/ModelTable/Column.ts +17 -0
- package/UI/Components/ModelTable/ColumnCustomizationModal.tsx +420 -0
- package/UI/Components/ModelTable/ColumnPreference.ts +482 -0
- package/UI/Components/ModelTable/CustomFieldColumns.tsx +326 -0
- package/UI/Components/ModelTable/TableView.tsx +24 -2
- package/UI/Components/ModelTable/useCustomFieldColumns.ts +150 -0
- package/UI/Components/SideMenu/SideMenu.tsx +24 -4
- package/UI/Components/Table/Table.tsx +14 -1
- package/UI/Components/Table/TableRow.tsx +180 -175
- package/UI/Components/Table/Types/Column.ts +2 -0
- package/Utils/UserPreferences.ts +53 -0
- package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js +86 -0
- package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TableView.js +40 -0
- package/build/dist/Models/DatabaseModels/TableView.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.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/Types/JSONFunctions.js +8 -3
- package/build/dist/Types/JSONFunctions.js.map +1 -1
- package/build/dist/Types/Kubernetes/KubernetesCostIngest.js.map +1 -1
- package/build/dist/UI/Components/MasterPage/MasterPage.js +50 -1
- package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +239 -7
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js +189 -0
- package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js.map +1 -0
- package/build/dist/UI/Components/ModelTable/ColumnPreference.js +258 -0
- package/build/dist/UI/Components/ModelTable/ColumnPreference.js.map +1 -0
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +168 -0
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -0
- package/build/dist/UI/Components/ModelTable/TableView.js +13 -2
- package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +84 -0
- package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -0
- package/build/dist/UI/Components/SideMenu/SideMenu.js +15 -5
- package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
- package/build/dist/UI/Components/Table/Table.js +15 -2
- package/build/dist/UI/Components/Table/Table.js.map +1 -1
- package/build/dist/UI/Components/Table/TableRow.js +11 -6
- package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
- package/build/dist/Utils/UserPreferences.js +33 -0
- package/build/dist/Utils/UserPreferences.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import UserPreferences, {
|
|
2
|
+
UserPreferenceType,
|
|
3
|
+
} from "../../Utils/UserPreferences";
|
|
4
|
+
import { JSONObject } from "../../Types/JSON";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* UserPreferences is the only thing standing between a table's saved layout and
|
|
9
|
+
* real browser storage, so the two properties worth pinning are:
|
|
10
|
+
*
|
|
11
|
+
* 1. Namespacing. Every entry lands under `${UserPreferenceType}.${key}`. Two
|
|
12
|
+
* tables, and two kinds of preference on the same table, must never land in
|
|
13
|
+
* the same slot - a collision there means changing a page size silently
|
|
14
|
+
* wipes a column layout.
|
|
15
|
+
* 2. The read guard. localStorage is user-editable and can be left half
|
|
16
|
+
* written by a closing tab, so anything that comes back as the wrong type
|
|
17
|
+
* has to degrade to "no preference" instead of being handed to the caller,
|
|
18
|
+
* which would spread the bad value through the column-preference code.
|
|
19
|
+
*
|
|
20
|
+
* These run against the real jsdom localStorage rather than a mock, so the raw
|
|
21
|
+
* keys asserted below are exactly what ships in a browser. Jest runs with
|
|
22
|
+
* --runInBand and storage leaks between files, hence the clear() below.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const TABLE_KEY: string = "monitors-table";
|
|
26
|
+
const OTHER_TABLE_KEY: string = "incidents-table";
|
|
27
|
+
|
|
28
|
+
type ReadRawFn = (key: string) => string | null;
|
|
29
|
+
|
|
30
|
+
const readRaw: ReadRawFn = (key: string): string | null => {
|
|
31
|
+
return window.localStorage.getItem(key);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("UserPreferences", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
window.localStorage.clear();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
window.localStorage.clear();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("UserPreferenceType", () => {
|
|
44
|
+
/*
|
|
45
|
+
* These strings are baked into every user's localStorage. Renaming one does
|
|
46
|
+
* not migrate anything - it just makes every saved preference unreachable,
|
|
47
|
+
* so everyone's table silently resets to the default layout.
|
|
48
|
+
*/
|
|
49
|
+
test("exposes the stable persisted key for column layouts", () => {
|
|
50
|
+
expect(UserPreferenceType.BaseModelTableColumns).toBe(
|
|
51
|
+
"BaseModelTableColumns",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("keeps the pre-existing page size key unchanged", () => {
|
|
56
|
+
expect(UserPreferenceType.BaseModelTablePageSize).toBe(
|
|
57
|
+
"BaseModelTablePageSize",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("key namespacing", () => {
|
|
63
|
+
test("stores a number under `${UserPreferenceType}.${key}`", () => {
|
|
64
|
+
UserPreferences.saveUserPreferenceByTypeAsNumber({
|
|
65
|
+
key: TABLE_KEY,
|
|
66
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
67
|
+
value: 25,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(readRaw("BaseModelTablePageSize.monitors-table")).toBe("25");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("stores an object under `${UserPreferenceType}.${key}`", () => {
|
|
74
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
75
|
+
key: TABLE_KEY,
|
|
76
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
77
|
+
value: { order: ["name"], hidden: [] },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const raw: string | null = readRaw(
|
|
81
|
+
"BaseModelTableColumns.monitors-table",
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(raw).not.toBeNull();
|
|
85
|
+
expect(JSON.parse(raw as string)).toEqual({
|
|
86
|
+
order: ["name"],
|
|
87
|
+
hidden: [],
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Two tables on the same page must not overwrite each other's layout.
|
|
92
|
+
test("two tables with different keys do not collide", () => {
|
|
93
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
94
|
+
key: TABLE_KEY,
|
|
95
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
96
|
+
value: { order: ["monitor-name"], hidden: ["monitor-status"] },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
100
|
+
key: OTHER_TABLE_KEY,
|
|
101
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
102
|
+
value: { order: ["incident-title"], hidden: [] },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(readRaw("BaseModelTableColumns.monitors-table")).not.toBe(
|
|
106
|
+
readRaw("BaseModelTableColumns.incidents-table"),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(
|
|
110
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
111
|
+
key: TABLE_KEY,
|
|
112
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
113
|
+
}),
|
|
114
|
+
).toEqual({ order: ["monitor-name"], hidden: ["monitor-status"] });
|
|
115
|
+
|
|
116
|
+
expect(
|
|
117
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
118
|
+
key: OTHER_TABLE_KEY,
|
|
119
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
120
|
+
}),
|
|
121
|
+
).toEqual({ order: ["incident-title"], hidden: [] });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/*
|
|
125
|
+
* One table holds both a page size and a column layout under the same
|
|
126
|
+
* userPreferencesKey. The type prefix is the only thing keeping them apart.
|
|
127
|
+
*/
|
|
128
|
+
test("two preference types on the same table key do not collide", () => {
|
|
129
|
+
UserPreferences.saveUserPreferenceByTypeAsNumber({
|
|
130
|
+
key: TABLE_KEY,
|
|
131
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
132
|
+
value: 50,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
136
|
+
key: TABLE_KEY,
|
|
137
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
138
|
+
value: { order: ["name"], hidden: ["description"] },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(readRaw("BaseModelTablePageSize.monitors-table")).toBe("50");
|
|
142
|
+
expect(readRaw("BaseModelTableColumns.monitors-table")).not.toBeNull();
|
|
143
|
+
|
|
144
|
+
expect(
|
|
145
|
+
UserPreferences.getUserPreferenceByTypeAsNumber({
|
|
146
|
+
key: TABLE_KEY,
|
|
147
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
148
|
+
}),
|
|
149
|
+
).toBe(50);
|
|
150
|
+
|
|
151
|
+
expect(
|
|
152
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
153
|
+
key: TABLE_KEY,
|
|
154
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
155
|
+
}),
|
|
156
|
+
).toEqual({ order: ["name"], hidden: ["description"] });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a value saved under one type is invisible to another type", () => {
|
|
160
|
+
UserPreferences.saveUserPreferenceByTypeAsNumber({
|
|
161
|
+
key: TABLE_KEY,
|
|
162
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
163
|
+
value: 25,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(
|
|
167
|
+
UserPreferences.getUserPreferenceByTypeAsNumber({
|
|
168
|
+
key: TABLE_KEY,
|
|
169
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
170
|
+
}),
|
|
171
|
+
).toBeNull();
|
|
172
|
+
|
|
173
|
+
expect(
|
|
174
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
175
|
+
key: TABLE_KEY,
|
|
176
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
177
|
+
}),
|
|
178
|
+
).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("number preferences", () => {
|
|
183
|
+
test("saves and reads back a page size", () => {
|
|
184
|
+
UserPreferences.saveUserPreferenceByTypeAsNumber({
|
|
185
|
+
key: TABLE_KEY,
|
|
186
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
187
|
+
value: 25,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(
|
|
191
|
+
UserPreferences.getUserPreferenceByTypeAsNumber({
|
|
192
|
+
key: TABLE_KEY,
|
|
193
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
194
|
+
}),
|
|
195
|
+
).toBe(25);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// A number must not come back as the string localStorage actually holds.
|
|
199
|
+
test("reads back a number, not the stored string", () => {
|
|
200
|
+
UserPreferences.saveUserPreferenceByTypeAsNumber({
|
|
201
|
+
key: TABLE_KEY,
|
|
202
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
203
|
+
value: 10,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(
|
|
207
|
+
typeof UserPreferences.getUserPreferenceByTypeAsNumber({
|
|
208
|
+
key: TABLE_KEY,
|
|
209
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
210
|
+
}),
|
|
211
|
+
).toBe("number");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("the newest value wins", () => {
|
|
215
|
+
UserPreferences.saveUserPreferenceByTypeAsNumber({
|
|
216
|
+
key: TABLE_KEY,
|
|
217
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
218
|
+
value: 10,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
UserPreferences.saveUserPreferenceByTypeAsNumber({
|
|
222
|
+
key: TABLE_KEY,
|
|
223
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
224
|
+
value: 50,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(
|
|
228
|
+
UserPreferences.getUserPreferenceByTypeAsNumber({
|
|
229
|
+
key: TABLE_KEY,
|
|
230
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
231
|
+
}),
|
|
232
|
+
).toBe(50);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("returns null when nothing was ever saved", () => {
|
|
236
|
+
expect(
|
|
237
|
+
UserPreferences.getUserPreferenceByTypeAsNumber({
|
|
238
|
+
key: "never-rendered-table",
|
|
239
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
240
|
+
}),
|
|
241
|
+
).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("JSON preferences", () => {
|
|
246
|
+
test("round trips a column preference", () => {
|
|
247
|
+
const preference: JSONObject = {
|
|
248
|
+
order: ["name", "currentMonitorStatus", "labels"],
|
|
249
|
+
hidden: ["description"],
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
253
|
+
key: TABLE_KEY,
|
|
254
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
255
|
+
value: preference,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(
|
|
259
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
260
|
+
key: TABLE_KEY,
|
|
261
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
262
|
+
}),
|
|
263
|
+
).toEqual(preference);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
/*
|
|
267
|
+
* The column preference is nothing but arrays of ids, and LocalStorage runs
|
|
268
|
+
* everything through a serializer on the way in and out - so the arrays,
|
|
269
|
+
* their order, and empty arrays all have to survive intact.
|
|
270
|
+
*/
|
|
271
|
+
test("arrays keep their contents and their order", () => {
|
|
272
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
273
|
+
key: TABLE_KEY,
|
|
274
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
275
|
+
value: {
|
|
276
|
+
order: ["c", "a", "b"],
|
|
277
|
+
hidden: [],
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const preference: JSONObject | null =
|
|
282
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
283
|
+
key: TABLE_KEY,
|
|
284
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(preference?.["order"]).toEqual(["c", "a", "b"]);
|
|
288
|
+
expect(preference?.["hidden"]).toEqual([]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("arrays nested inside a nested object survive", () => {
|
|
292
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
293
|
+
key: TABLE_KEY,
|
|
294
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
295
|
+
value: {
|
|
296
|
+
columns: {
|
|
297
|
+
order: ["name", "customField.severity"],
|
|
298
|
+
hidden: ["createdAt"],
|
|
299
|
+
},
|
|
300
|
+
version: 1,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
expect(
|
|
305
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
306
|
+
key: TABLE_KEY,
|
|
307
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
308
|
+
}),
|
|
309
|
+
).toEqual({
|
|
310
|
+
columns: {
|
|
311
|
+
order: ["name", "customField.severity"],
|
|
312
|
+
hidden: ["createdAt"],
|
|
313
|
+
},
|
|
314
|
+
version: 1,
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
/*
|
|
319
|
+
* An empty object is a real, deliberately saved preference (the caller
|
|
320
|
+
* wrote it), not the absence of one - collapsing it to null would resurrect
|
|
321
|
+
* the default layout the user just cleared.
|
|
322
|
+
*/
|
|
323
|
+
test("an empty object is a preference, not an absent one", () => {
|
|
324
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
325
|
+
key: TABLE_KEY,
|
|
326
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
327
|
+
value: {},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(
|
|
331
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
332
|
+
key: TABLE_KEY,
|
|
333
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
334
|
+
}),
|
|
335
|
+
).toEqual({});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("returns null when nothing was ever saved", () => {
|
|
339
|
+
expect(
|
|
340
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
341
|
+
key: "never-rendered-table",
|
|
342
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
343
|
+
}),
|
|
344
|
+
).toBeNull();
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
/*
|
|
349
|
+
* Everything below writes straight into localStorage, which is what a
|
|
350
|
+
* hand-edited entry, an older build, or a tab that died mid-write leaves
|
|
351
|
+
* behind. None of it may reach the caller as anything but null: the column
|
|
352
|
+
* code expects an object and would happily read `.order` off a string.
|
|
353
|
+
*/
|
|
354
|
+
describe("JSON preferences reject anything that is not an object", () => {
|
|
355
|
+
const columnsKey: string = "BaseModelTableColumns.monitors-table";
|
|
356
|
+
|
|
357
|
+
type ReadPreferenceFn = () => JSONObject | null;
|
|
358
|
+
|
|
359
|
+
const readPreference: ReadPreferenceFn = (): JSONObject | null => {
|
|
360
|
+
return UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
361
|
+
key: TABLE_KEY,
|
|
362
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
363
|
+
});
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
test("a bare string that is not JSON at all", () => {
|
|
367
|
+
window.localStorage.setItem(columnsKey, "just-a-string");
|
|
368
|
+
|
|
369
|
+
expect(readPreference()).toBeNull();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("a quoted string that parses cleanly into a string", () => {
|
|
373
|
+
window.localStorage.setItem(columnsKey, JSON.stringify("name,status"));
|
|
374
|
+
|
|
375
|
+
expect(readPreference()).toBeNull();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("a number", () => {
|
|
379
|
+
// e.g. a build where this key used to hold a page size.
|
|
380
|
+
UserPreferences.saveUserPreferenceByTypeAsNumber({
|
|
381
|
+
key: TABLE_KEY,
|
|
382
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
383
|
+
value: 25,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
expect(readPreference()).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// An array of ids is not a preference object, even though it parses.
|
|
390
|
+
test("an array", () => {
|
|
391
|
+
window.localStorage.setItem(
|
|
392
|
+
columnsKey,
|
|
393
|
+
JSON.stringify(["name", "status"]),
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
expect(readPreference()).toBeNull();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("malformed JSON left behind by a half-finished write", () => {
|
|
400
|
+
window.localStorage.setItem(columnsKey, '{"order": ["name",');
|
|
401
|
+
|
|
402
|
+
expect(readPreference()).toBeNull();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("a stored null", () => {
|
|
406
|
+
window.localStorage.setItem(columnsKey, "null");
|
|
407
|
+
|
|
408
|
+
expect(readPreference()).toBeNull();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("a bad entry never throws and never returns a string", () => {
|
|
412
|
+
window.localStorage.setItem(columnsKey, '{"order": ["name",');
|
|
413
|
+
|
|
414
|
+
const preference: JSONObject | null = readPreference();
|
|
415
|
+
|
|
416
|
+
expect(typeof preference).not.toBe("string");
|
|
417
|
+
expect(preference).toBeNull();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// A rejected entry must not poison the neighbouring page size.
|
|
421
|
+
test("a bad column entry leaves the page size readable", () => {
|
|
422
|
+
UserPreferences.saveUserPreferenceByTypeAsNumber({
|
|
423
|
+
key: TABLE_KEY,
|
|
424
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
425
|
+
value: 50,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
window.localStorage.setItem(columnsKey, "not json");
|
|
429
|
+
|
|
430
|
+
expect(readPreference()).toBeNull();
|
|
431
|
+
expect(
|
|
432
|
+
UserPreferences.getUserPreferenceByTypeAsNumber({
|
|
433
|
+
key: TABLE_KEY,
|
|
434
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
435
|
+
}),
|
|
436
|
+
).toBe(50);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe("removeUserPreferenceByType", () => {
|
|
441
|
+
test("removes the value it owns", () => {
|
|
442
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
443
|
+
key: TABLE_KEY,
|
|
444
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
445
|
+
value: { order: ["name"], hidden: [] },
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
UserPreferences.removeUserPreferenceByType({
|
|
449
|
+
key: TABLE_KEY,
|
|
450
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
expect(readRaw("BaseModelTableColumns.monitors-table")).toBeNull();
|
|
454
|
+
expect(
|
|
455
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
456
|
+
key: TABLE_KEY,
|
|
457
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
458
|
+
}),
|
|
459
|
+
).toBeNull();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// "Reset columns" on one table must not reset every other table.
|
|
463
|
+
test("leaves the same preference type on another table alone", () => {
|
|
464
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
465
|
+
key: TABLE_KEY,
|
|
466
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
467
|
+
value: { order: ["name"], hidden: [] },
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
471
|
+
key: OTHER_TABLE_KEY,
|
|
472
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
473
|
+
value: { order: ["title"], hidden: [] },
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
UserPreferences.removeUserPreferenceByType({
|
|
477
|
+
key: TABLE_KEY,
|
|
478
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
expect(
|
|
482
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
483
|
+
key: OTHER_TABLE_KEY,
|
|
484
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
485
|
+
}),
|
|
486
|
+
).toEqual({ order: ["title"], hidden: [] });
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// ...nor the page size the same table stored under the same key.
|
|
490
|
+
test("leaves the other preference type on the same table alone", () => {
|
|
491
|
+
UserPreferences.saveUserPreferenceByTypeAsNumber({
|
|
492
|
+
key: TABLE_KEY,
|
|
493
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
494
|
+
value: 25,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
498
|
+
key: TABLE_KEY,
|
|
499
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
500
|
+
value: { order: ["name"], hidden: [] },
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
UserPreferences.removeUserPreferenceByType({
|
|
504
|
+
key: TABLE_KEY,
|
|
505
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
expect(readRaw("BaseModelTableColumns.monitors-table")).toBeNull();
|
|
509
|
+
expect(
|
|
510
|
+
UserPreferences.getUserPreferenceByTypeAsNumber({
|
|
511
|
+
key: TABLE_KEY,
|
|
512
|
+
userPreferenceType: UserPreferenceType.BaseModelTablePageSize,
|
|
513
|
+
}),
|
|
514
|
+
).toBe(25);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("removing something that was never saved is a no-op", () => {
|
|
518
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
519
|
+
key: TABLE_KEY,
|
|
520
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
521
|
+
value: { order: ["name"], hidden: [] },
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
UserPreferences.removeUserPreferenceByType({
|
|
525
|
+
key: "never-rendered-table",
|
|
526
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
expect(
|
|
530
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
531
|
+
key: TABLE_KEY,
|
|
532
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
533
|
+
}),
|
|
534
|
+
).toEqual({ order: ["name"], hidden: [] });
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("a removed preference can be saved again", () => {
|
|
538
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
539
|
+
key: TABLE_KEY,
|
|
540
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
541
|
+
value: { order: ["name"], hidden: [] },
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
UserPreferences.removeUserPreferenceByType({
|
|
545
|
+
key: TABLE_KEY,
|
|
546
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
UserPreferences.saveUserPreferenceByTypeAsJSON({
|
|
550
|
+
key: TABLE_KEY,
|
|
551
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
552
|
+
value: { order: ["status"], hidden: ["name"] },
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
expect(
|
|
556
|
+
UserPreferences.getUserPreferenceByTypeAsJSON({
|
|
557
|
+
key: TABLE_KEY,
|
|
558
|
+
userPreferenceType: UserPreferenceType.BaseModelTableColumns,
|
|
559
|
+
}),
|
|
560
|
+
).toEqual({ order: ["status"], hidden: ["name"] });
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
});
|
package/Types/JSONFunctions.ts
CHANGED
|
@@ -342,9 +342,12 @@ export default class JSONFunctions {
|
|
|
342
342
|
].fromJSON(val);
|
|
343
343
|
} else if (val instanceof Date) {
|
|
344
344
|
return val;
|
|
345
|
-
} else if (typeof val === Typeof.Object) {
|
|
346
|
-
return this.deserialize(val as JSONObject);
|
|
347
345
|
} else if (Array.isArray(val)) {
|
|
346
|
+
/*
|
|
347
|
+
* This has to be checked before the plain-object branch below: arrays are
|
|
348
|
+
* typeof "object", so falling through to deserialize() would walk them by
|
|
349
|
+
* key and hand back { "0": ..., "1": ... } instead of an array.
|
|
350
|
+
*/
|
|
348
351
|
const arr: Array<JSONValue> = [];
|
|
349
352
|
|
|
350
353
|
for (const v of val) {
|
|
@@ -352,6 +355,8 @@ export default class JSONFunctions {
|
|
|
352
355
|
}
|
|
353
356
|
|
|
354
357
|
return arr;
|
|
358
|
+
} else if (typeof val === Typeof.Object) {
|
|
359
|
+
return this.deserialize(val as JSONObject);
|
|
355
360
|
}
|
|
356
361
|
|
|
357
362
|
return val;
|
|
@@ -34,14 +34,30 @@ export interface KubernetesCostAllocationIngestRow {
|
|
|
34
34
|
providerId?: string | undefined;
|
|
35
35
|
labels?: Record<string, string> | undefined;
|
|
36
36
|
|
|
37
|
-
// Usage / request measures.
|
|
37
|
+
// Usage / request / limit measures.
|
|
38
38
|
cpuCoreHours?: number | undefined;
|
|
39
39
|
cpuCoreRequestAverage?: number | undefined;
|
|
40
40
|
cpuCoreUsageAverage?: number | undefined;
|
|
41
|
+
cpuCoreLimitAverage?: number | undefined;
|
|
41
42
|
gpuHours?: number | undefined;
|
|
42
43
|
ramByteHours?: number | undefined;
|
|
43
44
|
ramBytesRequestAverage?: number | undefined;
|
|
44
45
|
ramBytesUsageAverage?: number | undefined;
|
|
46
|
+
ramBytesLimitAverage?: number | undefined;
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Peak working set over the window, sourced from Prometheus rather than
|
|
50
|
+
* the cost engine — the Allocation API only reports averages, and an
|
|
51
|
+
* hourly mean hides the burst that OOMKills a container, so a memory
|
|
52
|
+
* recommendation built on the average is actively dangerous.
|
|
53
|
+
*
|
|
54
|
+
* 0 when the agent has no Prometheus configured (external-engine installs
|
|
55
|
+
* have no bundled one), when the scrape had no data for the window, or
|
|
56
|
+
* when the agent predates this field. The server cannot tell those apart
|
|
57
|
+
* from a real 0, so recommendations must require a positive value.
|
|
58
|
+
*/
|
|
59
|
+
ramBytesUsageMax?: number | undefined;
|
|
60
|
+
|
|
45
61
|
pvByteHours?: number | undefined;
|
|
46
62
|
|
|
47
63
|
// Pre-priced cost components (in `currency` of the payload).
|
|
@@ -69,6 +85,26 @@ export interface KubernetesCostIngestPayload {
|
|
|
69
85
|
clusterName: string;
|
|
70
86
|
/** Currency code of all cost figures (e.g. "USD"). Optional. */
|
|
71
87
|
currency?: string | undefined;
|
|
88
|
+
|
|
89
|
+
/*
|
|
90
|
+
* Identity of one shipment — the agent's delivery of ONE window — and this
|
|
91
|
+
* request's position within it.
|
|
92
|
+
*
|
|
93
|
+
* A window wider than the agent's batch size arrives as several requests,
|
|
94
|
+
* each becoming its own ingest job. Without an identity the server cannot
|
|
95
|
+
* tell "another chunk of the shipment I have already started" (accept)
|
|
96
|
+
* from "a window an earlier shipment already delivered in full" (drop, or
|
|
97
|
+
* a restart double-counts spend), so it dropped both and lost every row
|
|
98
|
+
* past the first chunk on clusters above SHIP_BATCH_SIZE containers.
|
|
99
|
+
*
|
|
100
|
+
* shipmentId is a content hash over the window's row identities, so the
|
|
101
|
+
* same window re-shipped after a restart hashes the same and its chunks
|
|
102
|
+
* dedup individually. Both are absent on agents older than this contract;
|
|
103
|
+
* the server then falls back to the whole-window guard.
|
|
104
|
+
*/
|
|
105
|
+
shipmentId?: string | undefined;
|
|
106
|
+
shipmentChunk?: number | undefined;
|
|
107
|
+
|
|
72
108
|
allocations: Array<KubernetesCostAllocationIngestRow>;
|
|
73
109
|
}
|
|
74
110
|
|