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
|
@@ -181,6 +181,8 @@ describe('webServer integration', () => {
|
|
|
181
181
|
consoleDataOutputDir: null,
|
|
182
182
|
inTmuxDataDir: null,
|
|
183
183
|
dashboardDir: null,
|
|
184
|
+
dashboardDataDir: null,
|
|
185
|
+
dashboardProjectCodes: [],
|
|
184
186
|
port: 0,
|
|
185
187
|
});
|
|
186
188
|
const address = server.address();
|
|
@@ -198,6 +200,8 @@ describe('webServer integration', () => {
|
|
|
198
200
|
consoleDataOutputDir: null,
|
|
199
201
|
inTmuxDataDir: null,
|
|
200
202
|
dashboardDir: null,
|
|
203
|
+
dashboardDataDir: null,
|
|
204
|
+
dashboardProjectCodes: [],
|
|
201
205
|
port: 0,
|
|
202
206
|
});
|
|
203
207
|
try {
|
|
@@ -224,6 +228,8 @@ describe('webServer integration', () => {
|
|
|
224
228
|
consoleDataOutputDir: null,
|
|
225
229
|
inTmuxDataDir: null,
|
|
226
230
|
dashboardDir: null,
|
|
231
|
+
dashboardDataDir: null,
|
|
232
|
+
dashboardProjectCodes: [],
|
|
227
233
|
port: 0,
|
|
228
234
|
});
|
|
229
235
|
try {
|
|
@@ -254,6 +260,8 @@ describe('webServer integration', () => {
|
|
|
254
260
|
consoleDataOutputDir: null,
|
|
255
261
|
inTmuxDataDir: null,
|
|
256
262
|
dashboardDir: null,
|
|
263
|
+
dashboardDataDir: null,
|
|
264
|
+
dashboardProjectCodes: [],
|
|
257
265
|
port: 0,
|
|
258
266
|
});
|
|
259
267
|
try {
|
|
@@ -286,6 +294,8 @@ describe('webServer integration', () => {
|
|
|
286
294
|
consoleDataOutputDir: null,
|
|
287
295
|
inTmuxDataDir: null,
|
|
288
296
|
dashboardDir: null,
|
|
297
|
+
dashboardDataDir: null,
|
|
298
|
+
dashboardProjectCodes: [],
|
|
289
299
|
port: 0,
|
|
290
300
|
});
|
|
291
301
|
try {
|
|
@@ -315,6 +325,8 @@ describe('webServer integration', () => {
|
|
|
315
325
|
consoleDataOutputDir: null,
|
|
316
326
|
inTmuxDataDir: null,
|
|
317
327
|
dashboardDir: null,
|
|
328
|
+
dashboardDataDir: null,
|
|
329
|
+
dashboardProjectCodes: [],
|
|
318
330
|
port: 0,
|
|
319
331
|
});
|
|
320
332
|
try {
|
|
@@ -338,6 +350,8 @@ describe('webServer integration', () => {
|
|
|
338
350
|
consoleDataOutputDir: null,
|
|
339
351
|
inTmuxDataDir: null,
|
|
340
352
|
dashboardDir: null,
|
|
353
|
+
dashboardDataDir: null,
|
|
354
|
+
dashboardProjectCodes: [],
|
|
341
355
|
port: 0,
|
|
342
356
|
});
|
|
343
357
|
try {
|
|
@@ -364,6 +378,8 @@ describe('webServer integration', () => {
|
|
|
364
378
|
consoleDataOutputDir: null,
|
|
365
379
|
inTmuxDataDir: null,
|
|
366
380
|
dashboardDir: null,
|
|
381
|
+
dashboardDataDir: null,
|
|
382
|
+
dashboardProjectCodes: [],
|
|
367
383
|
port: 0,
|
|
368
384
|
});
|
|
369
385
|
try {
|
|
@@ -502,6 +518,8 @@ describe('webServer new routes integration', () => {
|
|
|
502
518
|
consoleDataOutputDir: dataDir,
|
|
503
519
|
inTmuxDataDir: null,
|
|
504
520
|
dashboardDir: null,
|
|
521
|
+
dashboardDataDir: null,
|
|
522
|
+
dashboardProjectCodes: [],
|
|
505
523
|
port: 0,
|
|
506
524
|
});
|
|
507
525
|
try {
|
|
@@ -539,6 +557,8 @@ describe('webServer new routes integration', () => {
|
|
|
539
557
|
consoleDataOutputDir: null,
|
|
540
558
|
inTmuxDataDir: null,
|
|
541
559
|
dashboardDir: null,
|
|
560
|
+
dashboardDataDir: null,
|
|
561
|
+
dashboardProjectCodes: [],
|
|
542
562
|
issueRepository,
|
|
543
563
|
issueTitleStateCache: new IssueTitleStateCache(),
|
|
544
564
|
port: 0,
|
|
@@ -573,6 +593,8 @@ describe('webServer new routes integration', () => {
|
|
|
573
593
|
consoleDataOutputDir: null,
|
|
574
594
|
inTmuxDataDir: null,
|
|
575
595
|
dashboardDir: null,
|
|
596
|
+
dashboardDataDir: null,
|
|
597
|
+
dashboardProjectCodes: [],
|
|
576
598
|
issueRepository,
|
|
577
599
|
resolveProject: async (pjcode) =>
|
|
578
600
|
pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
|
|
@@ -622,6 +644,8 @@ describe('webServer new routes integration', () => {
|
|
|
622
644
|
consoleDataOutputDir: dataDir,
|
|
623
645
|
inTmuxDataDir: null,
|
|
624
646
|
dashboardDir: null,
|
|
647
|
+
dashboardDataDir: null,
|
|
648
|
+
dashboardProjectCodes: [],
|
|
625
649
|
issueRepository,
|
|
626
650
|
resolveProject: async (pjcode) =>
|
|
627
651
|
pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
|
|
@@ -668,6 +692,8 @@ describe('webServer new routes integration', () => {
|
|
|
668
692
|
consoleDataOutputDir: null,
|
|
669
693
|
inTmuxDataDir: null,
|
|
670
694
|
dashboardDir: null,
|
|
695
|
+
dashboardDataDir: null,
|
|
696
|
+
dashboardProjectCodes: [],
|
|
671
697
|
issueRepository,
|
|
672
698
|
resolveProject: async (pjcode) =>
|
|
673
699
|
pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
|
|
@@ -704,6 +730,8 @@ describe('webServer new routes integration', () => {
|
|
|
704
730
|
consoleDataOutputDir: null,
|
|
705
731
|
inTmuxDataDir: null,
|
|
706
732
|
dashboardDir: null,
|
|
733
|
+
dashboardDataDir: null,
|
|
734
|
+
dashboardProjectCodes: [],
|
|
707
735
|
issueRepository,
|
|
708
736
|
resolveProject: async (pjcode) =>
|
|
709
737
|
pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
|
|
@@ -755,6 +783,8 @@ describe('webServer new routes integration', () => {
|
|
|
755
783
|
consoleDataOutputDir: null,
|
|
756
784
|
inTmuxDataDir: null,
|
|
757
785
|
dashboardDir: null,
|
|
786
|
+
dashboardDataDir: null,
|
|
787
|
+
dashboardProjectCodes: [],
|
|
758
788
|
port: 0,
|
|
759
789
|
});
|
|
760
790
|
try {
|
|
@@ -809,6 +839,23 @@ describe('resolveFlatInTmuxFilePath', () => {
|
|
|
809
839
|
});
|
|
810
840
|
});
|
|
811
841
|
|
|
842
|
+
describe('resolveDashboardFilePath', () => {
|
|
843
|
+
const baseDir = path.join(os.tmpdir(), 'dashboard-static');
|
|
844
|
+
|
|
845
|
+
it('resolves /tdpm.txt under the dashboard dir', () => {
|
|
846
|
+
expect(resolveDashboardFilePath(baseDir, '/tdpm.txt')).toBe(
|
|
847
|
+
path.join(path.resolve(baseDir), 'tdpm.txt'),
|
|
848
|
+
);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it('returns null for any other path', () => {
|
|
852
|
+
expect(resolveDashboardFilePath(baseDir, '/tdpm.html')).toBeNull();
|
|
853
|
+
expect(resolveDashboardFilePath(baseDir, '/other.txt')).toBeNull();
|
|
854
|
+
expect(resolveDashboardFilePath(baseDir, '/')).toBeNull();
|
|
855
|
+
expect(resolveDashboardFilePath(baseDir, '/sub/tdpm.txt')).toBeNull();
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
|
|
812
859
|
describe('webServer flat in-tmux-by-human route integration', () => {
|
|
813
860
|
const testToken = 'integration-test-token-value';
|
|
814
861
|
|
|
@@ -884,6 +931,8 @@ describe('webServer flat in-tmux-by-human route integration', () => {
|
|
|
884
931
|
consoleDataOutputDir: null,
|
|
885
932
|
inTmuxDataDir,
|
|
886
933
|
dashboardDir: null,
|
|
934
|
+
dashboardDataDir: null,
|
|
935
|
+
dashboardProjectCodes: [],
|
|
887
936
|
port: 0,
|
|
888
937
|
});
|
|
889
938
|
return { server, tmpDir, inTmuxDataDir };
|
|
@@ -998,6 +1047,8 @@ describe('webServer flat in-tmux-by-human route integration', () => {
|
|
|
998
1047
|
consoleDataOutputDir: null,
|
|
999
1048
|
inTmuxDataDir: null,
|
|
1000
1049
|
dashboardDir: null,
|
|
1050
|
+
dashboardDataDir: null,
|
|
1051
|
+
dashboardProjectCodes: [],
|
|
1001
1052
|
port: 0,
|
|
1002
1053
|
});
|
|
1003
1054
|
try {
|
|
@@ -1013,23 +1064,6 @@ describe('webServer flat in-tmux-by-human route integration', () => {
|
|
|
1013
1064
|
});
|
|
1014
1065
|
});
|
|
1015
1066
|
|
|
1016
|
-
describe('resolveDashboardFilePath', () => {
|
|
1017
|
-
const baseDir = path.join(os.tmpdir(), 'dashboard-data');
|
|
1018
|
-
|
|
1019
|
-
it('resolves /tdpm.txt under the dashboard dir', () => {
|
|
1020
|
-
expect(resolveDashboardFilePath(baseDir, '/tdpm.txt')).toBe(
|
|
1021
|
-
path.join(path.resolve(baseDir), 'tdpm.txt'),
|
|
1022
|
-
);
|
|
1023
|
-
});
|
|
1024
|
-
|
|
1025
|
-
it('returns null for any other path', () => {
|
|
1026
|
-
expect(resolveDashboardFilePath(baseDir, '/tdpm.html')).toBeNull();
|
|
1027
|
-
expect(resolveDashboardFilePath(baseDir, '/other.txt')).toBeNull();
|
|
1028
|
-
expect(resolveDashboardFilePath(baseDir, '/')).toBeNull();
|
|
1029
|
-
expect(resolveDashboardFilePath(baseDir, '/sub/tdpm.txt')).toBeNull();
|
|
1030
|
-
});
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
1067
|
describe('webServer dashboard /tdpm.txt route integration', () => {
|
|
1034
1068
|
const testToken = 'integration-test-token-value';
|
|
1035
1069
|
|
|
@@ -1084,54 +1118,219 @@ describe('webServer dashboard /tdpm.txt route integration', () => {
|
|
|
1084
1118
|
});
|
|
1085
1119
|
});
|
|
1086
1120
|
|
|
1087
|
-
const
|
|
1088
|
-
'
|
|
1121
|
+
const writeDataFiles = (dataDir: string): void => {
|
|
1122
|
+
fs.mkdirSync(path.join(dataDir, 'projects'), { recursive: true });
|
|
1123
|
+
fs.writeFileSync(
|
|
1124
|
+
path.join(dataDir, 'projects', 'um.json'),
|
|
1125
|
+
JSON.stringify({
|
|
1126
|
+
pjcode: 'um',
|
|
1127
|
+
capturedAt: '2026-06-26T00:00:00.000Z',
|
|
1128
|
+
unread: 3,
|
|
1129
|
+
todo: 1,
|
|
1130
|
+
qc: 2,
|
|
1131
|
+
fail: 0,
|
|
1132
|
+
pr: 0,
|
|
1133
|
+
ws: 4,
|
|
1134
|
+
dep: 1,
|
|
1135
|
+
blocker: 0,
|
|
1136
|
+
}),
|
|
1137
|
+
);
|
|
1138
|
+
fs.writeFileSync(
|
|
1139
|
+
path.join(dataDir, 'projects', 'xc.json'),
|
|
1140
|
+
JSON.stringify({
|
|
1141
|
+
pjcode: 'xc',
|
|
1142
|
+
capturedAt: '2026-06-26T00:00:00.000Z',
|
|
1143
|
+
unread: 0,
|
|
1144
|
+
todo: 0,
|
|
1145
|
+
qc: 0,
|
|
1146
|
+
fail: 0,
|
|
1147
|
+
pr: 0,
|
|
1148
|
+
ws: 2,
|
|
1149
|
+
dep: 0,
|
|
1150
|
+
blocker: 0,
|
|
1151
|
+
}),
|
|
1152
|
+
);
|
|
1153
|
+
fs.writeFileSync(
|
|
1154
|
+
path.join(dataDir, 'machine-status.json'),
|
|
1155
|
+
JSON.stringify({
|
|
1156
|
+
memPct: 55,
|
|
1157
|
+
cpuPct: 62,
|
|
1158
|
+
diskPct: 89,
|
|
1159
|
+
load: [16, 23, 40],
|
|
1160
|
+
cycleMinutes: 14,
|
|
1161
|
+
capturedAt: '2026-06-26T00:00:00.000Z',
|
|
1162
|
+
}),
|
|
1163
|
+
);
|
|
1164
|
+
fs.writeFileSync(
|
|
1165
|
+
path.join(dataDir, 'token-status.json'),
|
|
1166
|
+
JSON.stringify({
|
|
1167
|
+
tokens: [
|
|
1168
|
+
{
|
|
1169
|
+
name: 'alice',
|
|
1170
|
+
fiveHourUtilizationPercent: 10,
|
|
1171
|
+
fiveHourResetSeconds: 3600,
|
|
1172
|
+
sevenDayUtilizationPercent: 12,
|
|
1173
|
+
sevenDayResetSeconds: 432000,
|
|
1174
|
+
color: 'G',
|
|
1175
|
+
prep: 2,
|
|
1176
|
+
hum: 1,
|
|
1177
|
+
},
|
|
1178
|
+
],
|
|
1179
|
+
capturedAt: '2026-06-26T00:00:00.000Z',
|
|
1180
|
+
}),
|
|
1181
|
+
);
|
|
1182
|
+
};
|
|
1089
1183
|
|
|
1090
|
-
const
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1184
|
+
const expectedComposed =
|
|
1185
|
+
'<tt>M55% C62% D89% cy14</tt><br>\n' +
|
|
1186
|
+
'<tt>LA 16 23 40</tt><br>\n' +
|
|
1187
|
+
'<tt>pj unr tdo aqc fal prp aws dep</tt><br>\n' +
|
|
1188
|
+
'<tt>🟢um 3 1 2 0 0 4 1</tt><br>\n' +
|
|
1189
|
+
'<tt>🟢xc 0 0 0 0 0 2 0</tt><br>\n' +
|
|
1190
|
+
'<tt></tt><br>\n' +
|
|
1191
|
+
'<tt>🟢alice 10% 0d01h00 12% 5d00h00 2 1</tt><br>\n';
|
|
1192
|
+
|
|
1193
|
+
const staticDashboardRaw =
|
|
1194
|
+
'<tt>STATIC DASHBOARD</tt><br>\n<tt>pj unr tdo</tt><br>\n';
|
|
1195
|
+
|
|
1196
|
+
const writeStaticDashboard = (staticDir: string): void => {
|
|
1197
|
+
fs.writeFileSync(path.join(staticDir, 'tdpm.txt'), staticDashboardRaw);
|
|
1198
|
+
};
|
|
1199
|
+
|
|
1200
|
+
const startServer = async (overrides: {
|
|
1201
|
+
dashboardDir: string | null;
|
|
1202
|
+
dashboardDataDir: string | null;
|
|
1203
|
+
}): Promise<{ server: http.Server; tmpDir: string }> => {
|
|
1094
1204
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
|
|
1095
|
-
fs.writeFileSync(path.join(tmpDir, 'tdpm.txt'), dashboardRaw);
|
|
1096
|
-
fs.writeFileSync(path.join(tmpDir, 'secret.txt'), 'secret content');
|
|
1097
1205
|
const server = await startWebServer({
|
|
1098
1206
|
accessToken: testToken,
|
|
1099
1207
|
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
1100
1208
|
consoleDataOutputDir: null,
|
|
1101
1209
|
inTmuxDataDir: null,
|
|
1102
|
-
dashboardDir:
|
|
1210
|
+
dashboardDir: overrides.dashboardDir,
|
|
1211
|
+
dashboardDataDir: overrides.dashboardDataDir,
|
|
1212
|
+
dashboardProjectCodes: ['um', 'xc'],
|
|
1103
1213
|
port: 0,
|
|
1104
1214
|
});
|
|
1105
1215
|
return { server, tmpDir };
|
|
1106
1216
|
};
|
|
1107
1217
|
|
|
1108
|
-
it('
|
|
1109
|
-
const
|
|
1218
|
+
it('composes /tdpm.txt from the data files when dashboardDataDir is set and every file is present', async () => {
|
|
1219
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashboard-data-'));
|
|
1220
|
+
const staticDir = fs.mkdtempSync(
|
|
1221
|
+
path.join(os.tmpdir(), 'dashboard-static-'),
|
|
1222
|
+
);
|
|
1223
|
+
writeDataFiles(dataDir);
|
|
1224
|
+
writeStaticDashboard(staticDir);
|
|
1225
|
+
const { server, tmpDir } = await startServer({
|
|
1226
|
+
dashboardDir: staticDir,
|
|
1227
|
+
dashboardDataDir: dataDir,
|
|
1228
|
+
});
|
|
1110
1229
|
try {
|
|
1111
1230
|
const response = await requestServer(server, '/tdpm.txt');
|
|
1112
1231
|
expect(response.statusCode).toBe(200);
|
|
1113
|
-
expect(response.body).toBe(
|
|
1232
|
+
expect(response.body).toBe(expectedComposed);
|
|
1114
1233
|
expect(response.contentType).toBe('text/html; charset=utf-8');
|
|
1115
1234
|
expect(response.contentLength).toBe(
|
|
1116
|
-
String(Buffer.byteLength(
|
|
1235
|
+
String(Buffer.byteLength(expectedComposed)),
|
|
1117
1236
|
);
|
|
1118
1237
|
expect(response.transferEncoding).toBeUndefined();
|
|
1119
1238
|
expect(response.cacheControl).toBe('no-store');
|
|
1120
1239
|
} finally {
|
|
1121
1240
|
await closeServer(server);
|
|
1122
1241
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1242
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
1243
|
+
fs.rmSync(staticDir, { recursive: true, force: true });
|
|
1123
1244
|
}
|
|
1124
1245
|
});
|
|
1125
1246
|
|
|
1126
|
-
it('
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1247
|
+
it('serves the static tdpm.txt unchanged when dashboardDataDir is unset', async () => {
|
|
1248
|
+
const staticDir = fs.mkdtempSync(
|
|
1249
|
+
path.join(os.tmpdir(), 'dashboard-static-'),
|
|
1250
|
+
);
|
|
1251
|
+
writeStaticDashboard(staticDir);
|
|
1252
|
+
const { server, tmpDir } = await startServer({
|
|
1253
|
+
dashboardDir: staticDir,
|
|
1254
|
+
dashboardDataDir: null,
|
|
1255
|
+
});
|
|
1256
|
+
try {
|
|
1257
|
+
const response = await requestServer(server, '/tdpm.txt');
|
|
1258
|
+
expect(response.statusCode).toBe(200);
|
|
1259
|
+
expect(response.body).toBe(staticDashboardRaw);
|
|
1260
|
+
expect(response.contentType).toBe('text/html; charset=utf-8');
|
|
1261
|
+
expect(response.contentLength).toBe(
|
|
1262
|
+
String(Buffer.byteLength(staticDashboardRaw)),
|
|
1263
|
+
);
|
|
1264
|
+
expect(response.cacheControl).toBe('no-store');
|
|
1265
|
+
} finally {
|
|
1266
|
+
await closeServer(server);
|
|
1267
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1268
|
+
fs.rmSync(staticDir, { recursive: true, force: true });
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
it('falls back to the static tdpm.txt when dashboardDataDir is set but the data files are absent', async () => {
|
|
1273
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashboard-data-'));
|
|
1274
|
+
const staticDir = fs.mkdtempSync(
|
|
1275
|
+
path.join(os.tmpdir(), 'dashboard-static-'),
|
|
1276
|
+
);
|
|
1277
|
+
writeStaticDashboard(staticDir);
|
|
1278
|
+
const { server, tmpDir } = await startServer({
|
|
1279
|
+
dashboardDir: staticDir,
|
|
1280
|
+
dashboardDataDir: dataDir,
|
|
1281
|
+
});
|
|
1282
|
+
try {
|
|
1283
|
+
const response = await requestServer(server, '/tdpm.txt');
|
|
1284
|
+
expect(response.statusCode).toBe(200);
|
|
1285
|
+
expect(response.body).toBe(staticDashboardRaw);
|
|
1286
|
+
} finally {
|
|
1287
|
+
await closeServer(server);
|
|
1288
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1289
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
1290
|
+
fs.rmSync(staticDir, { recursive: true, force: true });
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
it('falls back to the static tdpm.txt when only some data files are present', async () => {
|
|
1295
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashboard-data-'));
|
|
1296
|
+
const staticDir = fs.mkdtempSync(
|
|
1297
|
+
path.join(os.tmpdir(), 'dashboard-static-'),
|
|
1298
|
+
);
|
|
1299
|
+
fs.writeFileSync(
|
|
1300
|
+
path.join(dataDir, 'machine-status.json'),
|
|
1301
|
+
JSON.stringify({
|
|
1302
|
+
memPct: 55,
|
|
1303
|
+
cpuPct: 62,
|
|
1304
|
+
diskPct: 89,
|
|
1305
|
+
load: [16, 23, 40],
|
|
1306
|
+
cycleMinutes: 14,
|
|
1307
|
+
capturedAt: '2026-06-26T00:00:00.000Z',
|
|
1308
|
+
}),
|
|
1309
|
+
);
|
|
1310
|
+
writeStaticDashboard(staticDir);
|
|
1311
|
+
const { server, tmpDir } = await startServer({
|
|
1312
|
+
dashboardDir: staticDir,
|
|
1313
|
+
dashboardDataDir: dataDir,
|
|
1314
|
+
});
|
|
1315
|
+
try {
|
|
1316
|
+
const response = await requestServer(server, '/tdpm.txt');
|
|
1317
|
+
expect(response.statusCode).toBe(200);
|
|
1318
|
+
expect(response.body).toBe(staticDashboardRaw);
|
|
1319
|
+
} finally {
|
|
1320
|
+
await closeServer(server);
|
|
1321
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1322
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
1323
|
+
fs.rmSync(staticDir, { recursive: true, force: true });
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
it('returns 404 for /tdpm.txt when the static file is absent and compose mode is inactive', async () => {
|
|
1328
|
+
const staticDir = fs.mkdtempSync(
|
|
1329
|
+
path.join(os.tmpdir(), 'dashboard-static-'),
|
|
1330
|
+
);
|
|
1331
|
+
const { server, tmpDir } = await startServer({
|
|
1332
|
+
dashboardDir: staticDir,
|
|
1333
|
+
dashboardDataDir: null,
|
|
1135
1334
|
});
|
|
1136
1335
|
try {
|
|
1137
1336
|
const response = await requestServer(server, '/tdpm.txt');
|
|
@@ -1139,23 +1338,15 @@ describe('webServer dashboard /tdpm.txt route integration', () => {
|
|
|
1139
1338
|
} finally {
|
|
1140
1339
|
await closeServer(server);
|
|
1141
1340
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1341
|
+
fs.rmSync(staticDir, { recursive: true, force: true });
|
|
1142
1342
|
}
|
|
1143
1343
|
});
|
|
1144
1344
|
|
|
1145
|
-
it('returns 404 for /tdpm.txt when dashboardDir
|
|
1146
|
-
const { server, tmpDir } = await (
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
accessToken: testToken,
|
|
1151
|
-
uiDistDir: path.join(dir, 'ui-dist'),
|
|
1152
|
-
consoleDataOutputDir: null,
|
|
1153
|
-
inTmuxDataDir: null,
|
|
1154
|
-
dashboardDir: null,
|
|
1155
|
-
port: 0,
|
|
1156
|
-
});
|
|
1157
|
-
return { server: srv, tmpDir: dir };
|
|
1158
|
-
})();
|
|
1345
|
+
it('returns 404 for /tdpm.txt when both dashboardDir and dashboardDataDir are null', async () => {
|
|
1346
|
+
const { server, tmpDir } = await startServer({
|
|
1347
|
+
dashboardDir: null,
|
|
1348
|
+
dashboardDataDir: null,
|
|
1349
|
+
});
|
|
1159
1350
|
try {
|
|
1160
1351
|
const response = await requestServer(server, '/tdpm.txt');
|
|
1161
1352
|
expect(response.statusCode).toBe(404);
|
|
@@ -1166,13 +1357,24 @@ describe('webServer dashboard /tdpm.txt route integration', () => {
|
|
|
1166
1357
|
});
|
|
1167
1358
|
|
|
1168
1359
|
it('rejects a non-GET method on /tdpm.txt with 404', async () => {
|
|
1169
|
-
const
|
|
1360
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashboard-data-'));
|
|
1361
|
+
const staticDir = fs.mkdtempSync(
|
|
1362
|
+
path.join(os.tmpdir(), 'dashboard-static-'),
|
|
1363
|
+
);
|
|
1364
|
+
writeDataFiles(dataDir);
|
|
1365
|
+
writeStaticDashboard(staticDir);
|
|
1366
|
+
const { server, tmpDir } = await startServer({
|
|
1367
|
+
dashboardDir: staticDir,
|
|
1368
|
+
dashboardDataDir: dataDir,
|
|
1369
|
+
});
|
|
1170
1370
|
try {
|
|
1171
1371
|
const response = await requestServer(server, '/tdpm.txt', 'POST');
|
|
1172
1372
|
expect(response.statusCode).toBe(404);
|
|
1173
1373
|
} finally {
|
|
1174
1374
|
await closeServer(server);
|
|
1175
1375
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1376
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
1377
|
+
fs.rmSync(staticDir, { recursive: true, force: true });
|
|
1176
1378
|
}
|
|
1177
1379
|
});
|
|
1178
1380
|
});
|
|
@@ -1250,6 +1452,8 @@ describe('webServer image proxy', () => {
|
|
|
1250
1452
|
consoleDataOutputDir: null,
|
|
1251
1453
|
inTmuxDataDir: null,
|
|
1252
1454
|
dashboardDir: null,
|
|
1455
|
+
dashboardDataDir: null,
|
|
1456
|
+
dashboardProjectCodes: [],
|
|
1253
1457
|
githubToken: token,
|
|
1254
1458
|
imageFetcher: fetcher,
|
|
1255
1459
|
port: 0,
|
|
@@ -26,9 +26,15 @@ import {
|
|
|
26
26
|
handleTriage,
|
|
27
27
|
} from './consoleOperationApi';
|
|
28
28
|
import { ImageFetcher, fetchProxiedImage } from './consoleImageProxy';
|
|
29
|
+
import {
|
|
30
|
+
composeDashboardText,
|
|
31
|
+
dashboardComposeFilesPresent,
|
|
32
|
+
} from './dashboardComposeService';
|
|
29
33
|
|
|
30
34
|
export const DEFAULT_WEB_PORT = 9981;
|
|
31
35
|
|
|
36
|
+
export const DEFAULT_DASHBOARD_PROJECT_CODES = ['um', 'xm', 'xc', 'ut'];
|
|
37
|
+
|
|
32
38
|
export const CONSOLE_TOKEN_HEADER = 'x-pv-token';
|
|
33
39
|
|
|
34
40
|
const PLACEHOLDER_INDEX_HTML = `<!DOCTYPE html>
|
|
@@ -158,6 +164,8 @@ export type WebServerOptions = {
|
|
|
158
164
|
consoleDataOutputDir: string | null;
|
|
159
165
|
inTmuxDataDir: string | null;
|
|
160
166
|
dashboardDir: string | null;
|
|
167
|
+
dashboardDataDir: string | null;
|
|
168
|
+
dashboardProjectCodes: string[];
|
|
161
169
|
githubToken?: string | null;
|
|
162
170
|
imageFetcher?: ImageFetcher | null;
|
|
163
171
|
issueRepository?: IssueRepository | null;
|
|
@@ -525,6 +533,40 @@ const handleTokenedRequest = async (
|
|
|
525
533
|
sendNotFound(response);
|
|
526
534
|
};
|
|
527
535
|
|
|
536
|
+
const readStaticDashboardContent = (
|
|
537
|
+
dashboardDir: string | null,
|
|
538
|
+
requestPath: string,
|
|
539
|
+
): Buffer | null => {
|
|
540
|
+
if (dashboardDir === null) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
const dashboardFilePath = resolveDashboardFilePath(dashboardDir, requestPath);
|
|
544
|
+
if (dashboardFilePath === null) {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
return readStaticFile(dashboardFilePath);
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
export const resolveDashboardContent = (
|
|
551
|
+
options: WebServerOptions,
|
|
552
|
+
requestPath: string,
|
|
553
|
+
): Buffer | null => {
|
|
554
|
+
if (
|
|
555
|
+
options.dashboardDataDir !== null &&
|
|
556
|
+
dashboardComposeFilesPresent({
|
|
557
|
+
dashboardDataDir: options.dashboardDataDir,
|
|
558
|
+
projectCodes: options.dashboardProjectCodes,
|
|
559
|
+
})
|
|
560
|
+
) {
|
|
561
|
+
const dashboardText = composeDashboardText({
|
|
562
|
+
dashboardDataDir: options.dashboardDataDir,
|
|
563
|
+
projectCodes: options.dashboardProjectCodes,
|
|
564
|
+
});
|
|
565
|
+
return Buffer.from(dashboardText, 'utf-8');
|
|
566
|
+
}
|
|
567
|
+
return readStaticDashboardContent(options.dashboardDir, requestPath);
|
|
568
|
+
};
|
|
569
|
+
|
|
528
570
|
export const handleWebRequest = async (
|
|
529
571
|
options: WebServerOptions,
|
|
530
572
|
request: http.IncomingMessage,
|
|
@@ -544,16 +586,7 @@ export const handleWebRequest = async (
|
|
|
544
586
|
sendNotFound(response);
|
|
545
587
|
return;
|
|
546
588
|
}
|
|
547
|
-
|
|
548
|
-
sendNotFound(response);
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
const dashboardFilePath = resolveDashboardFilePath(
|
|
552
|
-
options.dashboardDir,
|
|
553
|
-
requestPath,
|
|
554
|
-
);
|
|
555
|
-
const dashboardContent =
|
|
556
|
-
dashboardFilePath === null ? null : readStaticFile(dashboardFilePath);
|
|
589
|
+
const dashboardContent = resolveDashboardContent(options, requestPath);
|
|
557
590
|
if (dashboardContent === null) {
|
|
558
591
|
sendNotFound(response);
|
|
559
592
|
return;
|
|
@@ -25,7 +25,18 @@ const buildHostMetricsRepository = (
|
|
|
25
25
|
const sleep = async (): Promise<void> => {
|
|
26
26
|
fs.writeFileSync(statPath, 'cpu 660 0 0 440 0 0 0 0 0 0\n');
|
|
27
27
|
};
|
|
28
|
-
|
|
28
|
+
const readDiskBlocks = (): {
|
|
29
|
+
blocks: number;
|
|
30
|
+
bfree: number;
|
|
31
|
+
bavail: number;
|
|
32
|
+
} => ({ blocks: 1100, bfree: 200, bavail: 100 });
|
|
33
|
+
return {
|
|
34
|
+
repository: new ProcHostMetricsRepository(
|
|
35
|
+
procDirectory,
|
|
36
|
+
sleep,
|
|
37
|
+
readDiskBlocks,
|
|
38
|
+
),
|
|
39
|
+
};
|
|
29
40
|
};
|
|
30
41
|
|
|
31
42
|
describe('writeMachineStatus', () => {
|
|
@@ -42,7 +53,7 @@ describe('writeMachineStatus', () => {
|
|
|
42
53
|
fs.rmSync(procDirectory, { recursive: true, force: true });
|
|
43
54
|
});
|
|
44
55
|
|
|
45
|
-
it('writes machine-status.json with mem, cpu, load and cycle minutes', async () => {
|
|
56
|
+
it('writes machine-status.json with mem, cpu, disk, load and cycle minutes', async () => {
|
|
46
57
|
const { repository } = buildHostMetricsRepository(procDirectory);
|
|
47
58
|
const cacheDir = path.join(dir, 'allIssues-PVT');
|
|
48
59
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
@@ -65,6 +76,7 @@ describe('writeMachineStatus', () => {
|
|
|
65
76
|
const expected: MachineStatusFile = {
|
|
66
77
|
memPct: 55,
|
|
67
78
|
cpuPct: 60,
|
|
79
|
+
diskPct: 90,
|
|
68
80
|
load: [1.5, 0.8, 0.4],
|
|
69
81
|
cycleMinutes: 15,
|
|
70
82
|
capturedAt: '2026-06-26T12:00:00.000Z',
|
|
@@ -15,6 +15,7 @@ export type MachineStatusWriterParams = {
|
|
|
15
15
|
export type MachineStatusFile = {
|
|
16
16
|
memPct: number;
|
|
17
17
|
cpuPct: number;
|
|
18
|
+
diskPct: number;
|
|
18
19
|
load: [number, number, number];
|
|
19
20
|
cycleMinutes: number | null;
|
|
20
21
|
capturedAt: string;
|
|
@@ -61,6 +62,7 @@ export const writeMachineStatus = async (
|
|
|
61
62
|
|
|
62
63
|
const memPct = hostMetricsRepository.readMemoryUsedPercent();
|
|
63
64
|
const cpuPct = await hostMetricsRepository.readCpuUsedPercent();
|
|
65
|
+
const diskPct = hostMetricsRepository.readDiskUsedPercent();
|
|
64
66
|
const load = hostMetricsRepository.readLoadAverages();
|
|
65
67
|
|
|
66
68
|
const cycleMinutes = allIssuesCacheDir
|
|
@@ -70,6 +72,7 @@ export const writeMachineStatus = async (
|
|
|
70
72
|
const file: MachineStatusFile = {
|
|
71
73
|
memPct,
|
|
72
74
|
cpuPct,
|
|
75
|
+
diskPct,
|
|
73
76
|
load: [load.oneMinute, load.fiveMinute, load.fifteenMinute],
|
|
74
77
|
cycleMinutes,
|
|
75
78
|
capturedAt: (params.now ?? new Date()).toISOString(),
|
|
@@ -2,10 +2,12 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as os from 'os';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import {
|
|
5
|
+
DiskBlocks,
|
|
5
6
|
ProcHostMetricsRepository,
|
|
6
7
|
cpuUsedPercentFromSamples,
|
|
7
8
|
cycleMinutesFromMtimes,
|
|
8
9
|
parseCpuSample,
|
|
10
|
+
parseDiskUsedPercent,
|
|
9
11
|
parseLoadAverages,
|
|
10
12
|
parseMemoryUsedPercent,
|
|
11
13
|
} from './ProcHostMetricsRepository';
|
|
@@ -72,6 +74,20 @@ describe('parseLoadAverages', () => {
|
|
|
72
74
|
});
|
|
73
75
|
});
|
|
74
76
|
|
|
77
|
+
describe('parseDiskUsedPercent', () => {
|
|
78
|
+
it('excludes root-reserved blocks from the denominator like df', () => {
|
|
79
|
+
expect(parseDiskUsedPercent(1100, 200, 100)).toBe(90);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('matches df when there is no root reservation (bfree equals bavail)', () => {
|
|
83
|
+
expect(parseDiskUsedPercent(1000, 100, 100)).toBe(90);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('throws when the disk total is not positive', () => {
|
|
87
|
+
expect(() => parseDiskUsedPercent(100, 200, 0)).toThrow();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
75
91
|
describe('cycleMinutesFromMtimes', () => {
|
|
76
92
|
it('rounds the gap between the two newest generations to whole minutes', () => {
|
|
77
93
|
expect(cycleMinutesFromMtimes([1782469254.0, 1782468443.0])).toBe(14);
|
|
@@ -120,6 +136,16 @@ describe('ProcHostMetricsRepository', () => {
|
|
|
120
136
|
});
|
|
121
137
|
});
|
|
122
138
|
|
|
139
|
+
it('reads the df-style disk used percent from the root filesystem statfs', () => {
|
|
140
|
+
const diskBlocks: DiskBlocks = { blocks: 1100, bfree: 200, bavail: 100 };
|
|
141
|
+
const repository = new ProcHostMetricsRepository(
|
|
142
|
+
procDirectory,
|
|
143
|
+
async () => {},
|
|
144
|
+
() => diskBlocks,
|
|
145
|
+
);
|
|
146
|
+
expect(repository.readDiskUsedPercent()).toBe(90);
|
|
147
|
+
});
|
|
148
|
+
|
|
123
149
|
it('samples /proc/stat twice and computes the busy percent', async () => {
|
|
124
150
|
const statPath = path.join(procDirectory, 'stat');
|
|
125
151
|
let sampleIndex = 0;
|