github-issue-tower-defence-management 1.106.0 → 1.108.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.
- package/CHANGELOG.md +14 -0
- package/README.md +5 -0
- package/bin/adapter/entry-points/cli/index.js +18 -1
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/dashboardComposeService.js +179 -0
- package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -0
- package/bin/adapter/entry-points/console/webServer.js +29 -7
- package/bin/adapter/entry-points/console/webServer.js.map +1 -1
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js +2 -0
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -1
- package/bin/adapter/repositories/ProcHostMetricsRepository.js +25 -2
- package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -1
- package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +167 -0
- package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +36 -2
- package/src/adapter/entry-points/console/dashboardComposeService.test.ts +450 -0
- package/src/adapter/entry-points/console/dashboardComposeService.ts +201 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -0
- package/src/adapter/entry-points/console/webServer.test.ts +258 -54
- package/src/adapter/entry-points/console/webServer.ts +43 -10
- package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +14 -2
- package/src/adapter/entry-points/handlers/machineStatusWriter.ts +3 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +26 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.ts +36 -0
- package/src/domain/usecases/dashboard/ComposeDashboardUseCase.test.ts +432 -0
- package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +228 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/dashboardComposeService.d.ts +9 -0
- package/types/adapter/entry-points/console/dashboardComposeService.d.ts.map +1 -0
- package/types/adapter/entry-points/console/webServer.d.ts +4 -0
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +1 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -1
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +10 -1
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -1
- package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts +29 -0
- package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts.map +1 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
buildComposeDashboardInput,
|
|
6
|
+
composeDashboardText,
|
|
7
|
+
dashboardComposeFilesPresent,
|
|
8
|
+
} from './dashboardComposeService';
|
|
9
|
+
|
|
10
|
+
const makeDataDir = (): string =>
|
|
11
|
+
fs.mkdtempSync(path.join(os.tmpdir(), 'dashboard-compose-'));
|
|
12
|
+
|
|
13
|
+
const writeProject = (dataDir: string, code: string, body: unknown): void => {
|
|
14
|
+
fs.mkdirSync(path.join(dataDir, 'projects'), { recursive: true });
|
|
15
|
+
fs.writeFileSync(
|
|
16
|
+
path.join(dataDir, 'projects', `${code}.json`),
|
|
17
|
+
JSON.stringify(body),
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('buildComposeDashboardInput', () => {
|
|
22
|
+
it('reads every requested project code in order and yields null for absent files', () => {
|
|
23
|
+
const dataDir = makeDataDir();
|
|
24
|
+
try {
|
|
25
|
+
writeProject(dataDir, 'um', {
|
|
26
|
+
pjcode: 'um',
|
|
27
|
+
capturedAt: 'x',
|
|
28
|
+
unread: 3,
|
|
29
|
+
todo: 1,
|
|
30
|
+
qc: 2,
|
|
31
|
+
fail: 0,
|
|
32
|
+
pr: 0,
|
|
33
|
+
ws: 4,
|
|
34
|
+
dep: 1,
|
|
35
|
+
blocker: 0,
|
|
36
|
+
});
|
|
37
|
+
const input = buildComposeDashboardInput({
|
|
38
|
+
dashboardDataDir: dataDir,
|
|
39
|
+
projectCodes: ['um', 'xc'],
|
|
40
|
+
});
|
|
41
|
+
expect(input.projects).toEqual([
|
|
42
|
+
{
|
|
43
|
+
code: 'um',
|
|
44
|
+
row: {
|
|
45
|
+
unread: 3,
|
|
46
|
+
todo: 1,
|
|
47
|
+
qc: 2,
|
|
48
|
+
fail: 0,
|
|
49
|
+
pr: 0,
|
|
50
|
+
ws: 4,
|
|
51
|
+
dep: 1,
|
|
52
|
+
blocker: 0,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{ code: 'xc', row: null },
|
|
56
|
+
]);
|
|
57
|
+
} finally {
|
|
58
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('treats a project file with a missing field as an absent row', () => {
|
|
63
|
+
const dataDir = makeDataDir();
|
|
64
|
+
try {
|
|
65
|
+
writeProject(dataDir, 'um', { unread: 1, todo: 1 });
|
|
66
|
+
const input = buildComposeDashboardInput({
|
|
67
|
+
dashboardDataDir: dataDir,
|
|
68
|
+
projectCodes: ['um'],
|
|
69
|
+
});
|
|
70
|
+
expect(input.projects[0].row).toBeNull();
|
|
71
|
+
} finally {
|
|
72
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('treats malformed JSON as an absent row', () => {
|
|
77
|
+
const dataDir = makeDataDir();
|
|
78
|
+
try {
|
|
79
|
+
fs.mkdirSync(path.join(dataDir, 'projects'), { recursive: true });
|
|
80
|
+
fs.writeFileSync(path.join(dataDir, 'projects', 'um.json'), 'not json');
|
|
81
|
+
const input = buildComposeDashboardInput({
|
|
82
|
+
dashboardDataDir: dataDir,
|
|
83
|
+
projectCodes: ['um'],
|
|
84
|
+
});
|
|
85
|
+
expect(input.projects[0].row).toBeNull();
|
|
86
|
+
} finally {
|
|
87
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('reads machine status from machine-status.json', () => {
|
|
92
|
+
const dataDir = makeDataDir();
|
|
93
|
+
try {
|
|
94
|
+
fs.writeFileSync(
|
|
95
|
+
path.join(dataDir, 'machine-status.json'),
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
memPct: 55,
|
|
98
|
+
cpuPct: 62,
|
|
99
|
+
diskPct: 89,
|
|
100
|
+
load: [16, 23, 40],
|
|
101
|
+
cycleMinutes: 14,
|
|
102
|
+
capturedAt: 'x',
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
const input = buildComposeDashboardInput({
|
|
106
|
+
dashboardDataDir: dataDir,
|
|
107
|
+
projectCodes: [],
|
|
108
|
+
});
|
|
109
|
+
expect(input.machineStatus).toEqual({
|
|
110
|
+
memPct: 55,
|
|
111
|
+
cpuPct: 62,
|
|
112
|
+
diskPct: 89,
|
|
113
|
+
load: [16, 23, 40],
|
|
114
|
+
cycleMinutes: 14,
|
|
115
|
+
});
|
|
116
|
+
} finally {
|
|
117
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('preserves a null cycleMinutes from machine-status.json', () => {
|
|
122
|
+
const dataDir = makeDataDir();
|
|
123
|
+
try {
|
|
124
|
+
fs.writeFileSync(
|
|
125
|
+
path.join(dataDir, 'machine-status.json'),
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
memPct: 1,
|
|
128
|
+
cpuPct: 2,
|
|
129
|
+
diskPct: 3,
|
|
130
|
+
load: [0, 0, 0],
|
|
131
|
+
cycleMinutes: null,
|
|
132
|
+
capturedAt: 'x',
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
const input = buildComposeDashboardInput({
|
|
136
|
+
dashboardDataDir: dataDir,
|
|
137
|
+
projectCodes: [],
|
|
138
|
+
});
|
|
139
|
+
expect(input.machineStatus?.cycleMinutes).toBeNull();
|
|
140
|
+
} finally {
|
|
141
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('yields a null machine status when the file is absent', () => {
|
|
146
|
+
const dataDir = makeDataDir();
|
|
147
|
+
try {
|
|
148
|
+
const input = buildComposeDashboardInput({
|
|
149
|
+
dashboardDataDir: dataDir,
|
|
150
|
+
projectCodes: [],
|
|
151
|
+
});
|
|
152
|
+
expect(input.machineStatus).toBeNull();
|
|
153
|
+
} finally {
|
|
154
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('reads token statuses from token-status.json and skips malformed entries', () => {
|
|
159
|
+
const dataDir = makeDataDir();
|
|
160
|
+
try {
|
|
161
|
+
fs.writeFileSync(
|
|
162
|
+
path.join(dataDir, 'token-status.json'),
|
|
163
|
+
JSON.stringify({
|
|
164
|
+
tokens: [
|
|
165
|
+
{
|
|
166
|
+
name: 'alice',
|
|
167
|
+
fiveHourUtilizationPercent: 10,
|
|
168
|
+
fiveHourResetSeconds: 3600,
|
|
169
|
+
sevenDayUtilizationPercent: 12,
|
|
170
|
+
sevenDayResetSeconds: 432000,
|
|
171
|
+
color: 'G',
|
|
172
|
+
prep: 2,
|
|
173
|
+
hum: 1,
|
|
174
|
+
},
|
|
175
|
+
{ fiveHourUtilizationPercent: 5 },
|
|
176
|
+
],
|
|
177
|
+
capturedAt: 'x',
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
const input = buildComposeDashboardInput({
|
|
181
|
+
dashboardDataDir: dataDir,
|
|
182
|
+
projectCodes: [],
|
|
183
|
+
});
|
|
184
|
+
expect(input.tokens).toEqual([
|
|
185
|
+
{
|
|
186
|
+
name: 'alice',
|
|
187
|
+
fiveHourUtilizationPercent: 10,
|
|
188
|
+
fiveHourResetSeconds: 3600,
|
|
189
|
+
sevenDayUtilizationPercent: 12,
|
|
190
|
+
sevenDayResetSeconds: 432000,
|
|
191
|
+
color: 'G',
|
|
192
|
+
prep: 2,
|
|
193
|
+
hum: 1,
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
} finally {
|
|
197
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('defaults an unknown token color to Y and null windows to null', () => {
|
|
202
|
+
const dataDir = makeDataDir();
|
|
203
|
+
try {
|
|
204
|
+
fs.writeFileSync(
|
|
205
|
+
path.join(dataDir, 'token-status.json'),
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
tokens: [
|
|
208
|
+
{
|
|
209
|
+
name: 'bob',
|
|
210
|
+
fiveHourUtilizationPercent: null,
|
|
211
|
+
fiveHourResetSeconds: null,
|
|
212
|
+
sevenDayUtilizationPercent: null,
|
|
213
|
+
sevenDayResetSeconds: null,
|
|
214
|
+
color: 'purple',
|
|
215
|
+
prep: 0,
|
|
216
|
+
hum: 0,
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
capturedAt: 'x',
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
const input = buildComposeDashboardInput({
|
|
223
|
+
dashboardDataDir: dataDir,
|
|
224
|
+
projectCodes: [],
|
|
225
|
+
});
|
|
226
|
+
expect(input.tokens[0].color).toBe('Y');
|
|
227
|
+
expect(input.tokens[0].fiveHourUtilizationPercent).toBeNull();
|
|
228
|
+
} finally {
|
|
229
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('yields an empty token list when the file is absent', () => {
|
|
234
|
+
const dataDir = makeDataDir();
|
|
235
|
+
try {
|
|
236
|
+
const input = buildComposeDashboardInput({
|
|
237
|
+
dashboardDataDir: dataDir,
|
|
238
|
+
projectCodes: [],
|
|
239
|
+
});
|
|
240
|
+
expect(input.tokens).toEqual([]);
|
|
241
|
+
} finally {
|
|
242
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('composeDashboardText', () => {
|
|
248
|
+
it('composes the full byte-identical dashboard text from the data files', () => {
|
|
249
|
+
const dataDir = makeDataDir();
|
|
250
|
+
try {
|
|
251
|
+
writeProject(dataDir, 'um', {
|
|
252
|
+
pjcode: 'um',
|
|
253
|
+
capturedAt: 'x',
|
|
254
|
+
unread: 3,
|
|
255
|
+
todo: 1,
|
|
256
|
+
qc: 2,
|
|
257
|
+
fail: 0,
|
|
258
|
+
pr: 0,
|
|
259
|
+
ws: 4,
|
|
260
|
+
dep: 1,
|
|
261
|
+
blocker: 0,
|
|
262
|
+
});
|
|
263
|
+
fs.writeFileSync(
|
|
264
|
+
path.join(dataDir, 'machine-status.json'),
|
|
265
|
+
JSON.stringify({
|
|
266
|
+
memPct: 55,
|
|
267
|
+
cpuPct: 62,
|
|
268
|
+
diskPct: 89,
|
|
269
|
+
load: [16, 23, 40],
|
|
270
|
+
cycleMinutes: 14,
|
|
271
|
+
capturedAt: 'x',
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
fs.writeFileSync(
|
|
275
|
+
path.join(dataDir, 'token-status.json'),
|
|
276
|
+
JSON.stringify({
|
|
277
|
+
tokens: [
|
|
278
|
+
{
|
|
279
|
+
name: 'alice',
|
|
280
|
+
fiveHourUtilizationPercent: 10,
|
|
281
|
+
fiveHourResetSeconds: 3600,
|
|
282
|
+
sevenDayUtilizationPercent: 12,
|
|
283
|
+
sevenDayResetSeconds: 432000,
|
|
284
|
+
color: 'G',
|
|
285
|
+
prep: 2,
|
|
286
|
+
hum: 1,
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
capturedAt: 'x',
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
expect(
|
|
293
|
+
composeDashboardText({
|
|
294
|
+
dashboardDataDir: dataDir,
|
|
295
|
+
projectCodes: ['um', 'xc'],
|
|
296
|
+
}),
|
|
297
|
+
).toBe(
|
|
298
|
+
'<tt>M55% C62% D89% cy14</tt><br>\n' +
|
|
299
|
+
'<tt>LA 16 23 40</tt><br>\n' +
|
|
300
|
+
'<tt>pj unr tdo aqc fal prp aws dep</tt><br>\n' +
|
|
301
|
+
'<tt>🟢um 3 1 2 0 0 4 1</tt><br>\n' +
|
|
302
|
+
'<tt> xc -- -- -- -- -- -- --</tt><br>\n' +
|
|
303
|
+
'<tt></tt><br>\n' +
|
|
304
|
+
'<tt>🟢alice 10% 0d01h00 12% 5d00h00 2 1</tt><br>\n',
|
|
305
|
+
);
|
|
306
|
+
} finally {
|
|
307
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('dashboardComposeFilesPresent', () => {
|
|
313
|
+
const writeMachineAndToken = (dataDir: string): void => {
|
|
314
|
+
fs.writeFileSync(
|
|
315
|
+
path.join(dataDir, 'machine-status.json'),
|
|
316
|
+
JSON.stringify({
|
|
317
|
+
memPct: 1,
|
|
318
|
+
cpuPct: 2,
|
|
319
|
+
diskPct: 3,
|
|
320
|
+
load: [0, 0, 0],
|
|
321
|
+
cycleMinutes: null,
|
|
322
|
+
capturedAt: 'x',
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
fs.writeFileSync(
|
|
326
|
+
path.join(dataDir, 'token-status.json'),
|
|
327
|
+
JSON.stringify({ tokens: [], capturedAt: 'x' }),
|
|
328
|
+
);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const minimalProject = {
|
|
332
|
+
pjcode: 'um',
|
|
333
|
+
capturedAt: 'x',
|
|
334
|
+
unread: 0,
|
|
335
|
+
todo: 0,
|
|
336
|
+
qc: 0,
|
|
337
|
+
fail: 0,
|
|
338
|
+
pr: 0,
|
|
339
|
+
ws: 0,
|
|
340
|
+
dep: 0,
|
|
341
|
+
blocker: 0,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
it('is true when machine, token, and every requested project file are present', () => {
|
|
345
|
+
const dataDir = makeDataDir();
|
|
346
|
+
try {
|
|
347
|
+
writeMachineAndToken(dataDir);
|
|
348
|
+
writeProject(dataDir, 'um', minimalProject);
|
|
349
|
+
writeProject(dataDir, 'xc', { ...minimalProject, pjcode: 'xc' });
|
|
350
|
+
expect(
|
|
351
|
+
dashboardComposeFilesPresent({
|
|
352
|
+
dashboardDataDir: dataDir,
|
|
353
|
+
projectCodes: ['um', 'xc'],
|
|
354
|
+
}),
|
|
355
|
+
).toBe(true);
|
|
356
|
+
} finally {
|
|
357
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('is false when no data files exist', () => {
|
|
362
|
+
const dataDir = makeDataDir();
|
|
363
|
+
try {
|
|
364
|
+
expect(
|
|
365
|
+
dashboardComposeFilesPresent({
|
|
366
|
+
dashboardDataDir: dataDir,
|
|
367
|
+
projectCodes: ['um'],
|
|
368
|
+
}),
|
|
369
|
+
).toBe(false);
|
|
370
|
+
} finally {
|
|
371
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('is false when a requested project file is missing', () => {
|
|
376
|
+
const dataDir = makeDataDir();
|
|
377
|
+
try {
|
|
378
|
+
writeMachineAndToken(dataDir);
|
|
379
|
+
writeProject(dataDir, 'um', minimalProject);
|
|
380
|
+
expect(
|
|
381
|
+
dashboardComposeFilesPresent({
|
|
382
|
+
dashboardDataDir: dataDir,
|
|
383
|
+
projectCodes: ['um', 'xc'],
|
|
384
|
+
}),
|
|
385
|
+
).toBe(false);
|
|
386
|
+
} finally {
|
|
387
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('is false when machine-status.json is missing', () => {
|
|
392
|
+
const dataDir = makeDataDir();
|
|
393
|
+
try {
|
|
394
|
+
fs.writeFileSync(
|
|
395
|
+
path.join(dataDir, 'token-status.json'),
|
|
396
|
+
JSON.stringify({ tokens: [], capturedAt: 'x' }),
|
|
397
|
+
);
|
|
398
|
+
writeProject(dataDir, 'um', minimalProject);
|
|
399
|
+
expect(
|
|
400
|
+
dashboardComposeFilesPresent({
|
|
401
|
+
dashboardDataDir: dataDir,
|
|
402
|
+
projectCodes: ['um'],
|
|
403
|
+
}),
|
|
404
|
+
).toBe(false);
|
|
405
|
+
} finally {
|
|
406
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('is false when token-status.json is missing', () => {
|
|
411
|
+
const dataDir = makeDataDir();
|
|
412
|
+
try {
|
|
413
|
+
fs.writeFileSync(
|
|
414
|
+
path.join(dataDir, 'machine-status.json'),
|
|
415
|
+
JSON.stringify({
|
|
416
|
+
memPct: 1,
|
|
417
|
+
cpuPct: 2,
|
|
418
|
+
diskPct: 3,
|
|
419
|
+
load: [0, 0, 0],
|
|
420
|
+
cycleMinutes: null,
|
|
421
|
+
capturedAt: 'x',
|
|
422
|
+
}),
|
|
423
|
+
);
|
|
424
|
+
writeProject(dataDir, 'um', minimalProject);
|
|
425
|
+
expect(
|
|
426
|
+
dashboardComposeFilesPresent({
|
|
427
|
+
dashboardDataDir: dataDir,
|
|
428
|
+
projectCodes: ['um'],
|
|
429
|
+
}),
|
|
430
|
+
).toBe(false);
|
|
431
|
+
} finally {
|
|
432
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('is false when no project codes are requested', () => {
|
|
437
|
+
const dataDir = makeDataDir();
|
|
438
|
+
try {
|
|
439
|
+
writeMachineAndToken(dataDir);
|
|
440
|
+
expect(
|
|
441
|
+
dashboardComposeFilesPresent({
|
|
442
|
+
dashboardDataDir: dataDir,
|
|
443
|
+
projectCodes: [],
|
|
444
|
+
}),
|
|
445
|
+
).toBe(false);
|
|
446
|
+
} finally {
|
|
447
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
ComposeDashboardInput,
|
|
5
|
+
ComposeDashboardMachineStatus,
|
|
6
|
+
ComposeDashboardProject,
|
|
7
|
+
ComposeDashboardUseCase,
|
|
8
|
+
} from '../../../domain/usecases/dashboard/ComposeDashboardUseCase';
|
|
9
|
+
import { DashboardRow } from '../../../domain/usecases/dashboard/GenerateDashboardRowUseCase';
|
|
10
|
+
import {
|
|
11
|
+
TokenStatus,
|
|
12
|
+
TokenStatusColor,
|
|
13
|
+
} from '../../../domain/usecases/dashboard/GenerateTokenStatusUseCase';
|
|
14
|
+
|
|
15
|
+
export type DashboardComposeOptions = {
|
|
16
|
+
dashboardDataDir: string;
|
|
17
|
+
projectCodes: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
21
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
22
|
+
|
|
23
|
+
const readJsonFile = (filePath: string): unknown => {
|
|
24
|
+
let raw: string;
|
|
25
|
+
try {
|
|
26
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(raw);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const asFiniteNumber = (value: unknown): number | null =>
|
|
38
|
+
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
39
|
+
|
|
40
|
+
const parseDashboardRow = (value: unknown): DashboardRow | null => {
|
|
41
|
+
if (!isRecord(value)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const unread = asFiniteNumber(value.unread);
|
|
45
|
+
const todo = asFiniteNumber(value.todo);
|
|
46
|
+
const qc = asFiniteNumber(value.qc);
|
|
47
|
+
const fail = asFiniteNumber(value.fail);
|
|
48
|
+
const pr = asFiniteNumber(value.pr);
|
|
49
|
+
const ws = asFiniteNumber(value.ws);
|
|
50
|
+
const dep = asFiniteNumber(value.dep);
|
|
51
|
+
const blocker = asFiniteNumber(value.blocker);
|
|
52
|
+
if (
|
|
53
|
+
unread === null ||
|
|
54
|
+
todo === null ||
|
|
55
|
+
qc === null ||
|
|
56
|
+
fail === null ||
|
|
57
|
+
pr === null ||
|
|
58
|
+
ws === null ||
|
|
59
|
+
dep === null ||
|
|
60
|
+
blocker === null
|
|
61
|
+
) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return { unread, todo, qc, fail, pr, ws, dep, blocker };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const readProjectRow = (
|
|
68
|
+
dashboardDataDir: string,
|
|
69
|
+
code: string,
|
|
70
|
+
): DashboardRow | null =>
|
|
71
|
+
parseDashboardRow(
|
|
72
|
+
readJsonFile(path.join(dashboardDataDir, 'projects', `${code}.json`)),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const parseLoad = (value: unknown): [number, number, number] | null => {
|
|
76
|
+
if (!Array.isArray(value) || value.length !== 3) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const oneMinute = asFiniteNumber(value[0]);
|
|
80
|
+
const fiveMinute = asFiniteNumber(value[1]);
|
|
81
|
+
const fifteenMinute = asFiniteNumber(value[2]);
|
|
82
|
+
if (oneMinute === null || fiveMinute === null || fifteenMinute === null) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return [oneMinute, fiveMinute, fifteenMinute];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const readMachineStatus = (
|
|
89
|
+
dashboardDataDir: string,
|
|
90
|
+
): ComposeDashboardMachineStatus | null => {
|
|
91
|
+
const value = readJsonFile(
|
|
92
|
+
path.join(dashboardDataDir, 'machine-status.json'),
|
|
93
|
+
);
|
|
94
|
+
if (!isRecord(value)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const cycleMinutesRaw = value.cycleMinutes;
|
|
98
|
+
const cycleMinutes =
|
|
99
|
+
cycleMinutesRaw === null ? null : asFiniteNumber(cycleMinutesRaw);
|
|
100
|
+
return {
|
|
101
|
+
memPct: asFiniteNumber(value.memPct),
|
|
102
|
+
cpuPct: asFiniteNumber(value.cpuPct),
|
|
103
|
+
diskPct: asFiniteNumber(value.diskPct),
|
|
104
|
+
load: parseLoad(value.load),
|
|
105
|
+
cycleMinutes,
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const isTokenColor = (value: unknown): value is TokenStatusColor =>
|
|
110
|
+
value === 'G' || value === 'Y' || value === 'K';
|
|
111
|
+
|
|
112
|
+
const asTokenColor = (value: unknown): TokenStatusColor =>
|
|
113
|
+
isTokenColor(value) ? value : 'Y';
|
|
114
|
+
|
|
115
|
+
const asNullableNumber = (value: unknown): number | null =>
|
|
116
|
+
value === null ? null : asFiniteNumber(value);
|
|
117
|
+
|
|
118
|
+
const asCount = (value: unknown): number => {
|
|
119
|
+
const number = asFiniteNumber(value);
|
|
120
|
+
return number === null ? 0 : number;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const parseTokenStatus = (value: unknown): TokenStatus | null => {
|
|
124
|
+
if (!isRecord(value) || typeof value.name !== 'string') {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
name: value.name,
|
|
129
|
+
fiveHourUtilizationPercent: asNullableNumber(
|
|
130
|
+
value.fiveHourUtilizationPercent,
|
|
131
|
+
),
|
|
132
|
+
fiveHourResetSeconds: asNullableNumber(value.fiveHourResetSeconds),
|
|
133
|
+
sevenDayUtilizationPercent: asNullableNumber(
|
|
134
|
+
value.sevenDayUtilizationPercent,
|
|
135
|
+
),
|
|
136
|
+
sevenDayResetSeconds: asNullableNumber(value.sevenDayResetSeconds),
|
|
137
|
+
color: asTokenColor(value.color),
|
|
138
|
+
prep: asCount(value.prep),
|
|
139
|
+
hum: asCount(value.hum),
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const readTokenStatuses = (dashboardDataDir: string): TokenStatus[] => {
|
|
144
|
+
const value = readJsonFile(path.join(dashboardDataDir, 'token-status.json'));
|
|
145
|
+
if (!isRecord(value) || !Array.isArray(value.tokens)) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
const tokens: TokenStatus[] = [];
|
|
149
|
+
for (const entry of value.tokens) {
|
|
150
|
+
const token = parseTokenStatus(entry);
|
|
151
|
+
if (token !== null) {
|
|
152
|
+
tokens.push(token);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return tokens;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const buildComposeDashboardInput = (
|
|
159
|
+
options: DashboardComposeOptions,
|
|
160
|
+
): ComposeDashboardInput => {
|
|
161
|
+
const projects: ComposeDashboardProject[] = options.projectCodes.map(
|
|
162
|
+
(code) => ({
|
|
163
|
+
code,
|
|
164
|
+
row: readProjectRow(options.dashboardDataDir, code),
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
return {
|
|
168
|
+
projects,
|
|
169
|
+
machineStatus: readMachineStatus(options.dashboardDataDir),
|
|
170
|
+
tokens: readTokenStatuses(options.dashboardDataDir),
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const isExistingFile = (filePath: string): boolean => {
|
|
175
|
+
try {
|
|
176
|
+
return fs.statSync(filePath).isFile();
|
|
177
|
+
} catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const dashboardComposeFilesPresent = (
|
|
183
|
+
options: DashboardComposeOptions,
|
|
184
|
+
): boolean => {
|
|
185
|
+
if (options.projectCodes.length === 0) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const requiredFiles = [
|
|
189
|
+
path.join(options.dashboardDataDir, 'machine-status.json'),
|
|
190
|
+
path.join(options.dashboardDataDir, 'token-status.json'),
|
|
191
|
+
...options.projectCodes.map((code) =>
|
|
192
|
+
path.join(options.dashboardDataDir, 'projects', `${code}.json`),
|
|
193
|
+
),
|
|
194
|
+
];
|
|
195
|
+
return requiredFiles.every((filePath) => isExistingFile(filePath));
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const composeDashboardText = (
|
|
199
|
+
options: DashboardComposeOptions,
|
|
200
|
+
): string =>
|
|
201
|
+
new ComposeDashboardUseCase().run(buildComposeDashboardInput(options));
|
|
@@ -371,6 +371,8 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
|
|
|
371
371
|
issueTitleStateCache: new IssueTitleStateCache(),
|
|
372
372
|
inTmuxDataDir: null,
|
|
373
373
|
dashboardDir: null,
|
|
374
|
+
dashboardDataDir: null,
|
|
375
|
+
dashboardProjectCodes: [],
|
|
374
376
|
port: 0,
|
|
375
377
|
});
|
|
376
378
|
|