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.
Files changed (38) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -0
  3. package/bin/adapter/entry-points/cli/index.js +18 -1
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/dashboardComposeService.js +179 -0
  6. package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -0
  7. package/bin/adapter/entry-points/console/webServer.js +29 -7
  8. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/machineStatusWriter.js +2 -0
  10. package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -1
  11. package/bin/adapter/repositories/ProcHostMetricsRepository.js +25 -2
  12. package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -1
  13. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +167 -0
  14. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -0
  15. package/package.json +1 -1
  16. package/src/adapter/entry-points/cli/index.ts +36 -2
  17. package/src/adapter/entry-points/console/dashboardComposeService.test.ts +450 -0
  18. package/src/adapter/entry-points/console/dashboardComposeService.ts +201 -0
  19. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -0
  20. package/src/adapter/entry-points/console/webServer.test.ts +258 -54
  21. package/src/adapter/entry-points/console/webServer.ts +43 -10
  22. package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +14 -2
  23. package/src/adapter/entry-points/handlers/machineStatusWriter.ts +3 -0
  24. package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +26 -0
  25. package/src/adapter/repositories/ProcHostMetricsRepository.ts +36 -0
  26. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.test.ts +432 -0
  27. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +228 -0
  28. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  29. package/types/adapter/entry-points/console/dashboardComposeService.d.ts +9 -0
  30. package/types/adapter/entry-points/console/dashboardComposeService.d.ts.map +1 -0
  31. package/types/adapter/entry-points/console/webServer.d.ts +4 -0
  32. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  33. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +1 -0
  34. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -1
  35. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +10 -1
  36. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -1
  37. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts +29 -0
  38. 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 dashboardRaw =
1088
- '<tt>MEM&nbsp;30%</tt><br>\n<tt>pj&nbsp;unr&nbsp;tdo</tt><br>\n';
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 startWithDashboard = async (): Promise<{
1091
- server: http.Server;
1092
- tmpDir: string;
1093
- }> => {
1184
+ const expectedComposed =
1185
+ '<tt>M55%&nbsp;C62%&nbsp;D89%&nbsp;cy14</tt><br>\n' +
1186
+ '<tt>LA&nbsp;16&nbsp;23&nbsp;40</tt><br>\n' +
1187
+ '<tt>pj&nbsp;&nbsp;&nbsp;unr&nbsp;tdo&nbsp;aqc&nbsp;fal&nbsp;prp&nbsp;aws&nbsp;dep</tt><br>\n' +
1188
+ '<tt>🟢um&nbsp;&nbsp;&nbsp;3&nbsp;&nbsp;&nbsp;1&nbsp;&nbsp;&nbsp;2&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;4&nbsp;&nbsp;&nbsp;1</tt><br>\n' +
1189
+ '<tt>🟢xc&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;0&nbsp;&nbsp;&nbsp;2&nbsp;&nbsp;&nbsp;0</tt><br>\n' +
1190
+ '<tt></tt><br>\n' +
1191
+ '<tt>🟢alice&nbsp;&nbsp;10%&nbsp;0d01h00&nbsp;&nbsp;12%&nbsp;5d00h00&nbsp;2&nbsp;1</tt><br>\n';
1192
+
1193
+ const staticDashboardRaw =
1194
+ '<tt>STATIC&nbsp;DASHBOARD</tt><br>\n<tt>pj&nbsp;unr&nbsp;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: tmpDir,
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('serves /tdpm.txt without a token, byte-identical, as text/html with an explicit Content-Length and no chunked encoding', async () => {
1109
- const { server, tmpDir } = await startWithDashboard();
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(dashboardRaw);
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(dashboardRaw)),
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('returns 404 for /tdpm.txt when the file is absent', async () => {
1127
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
1128
- const server = await startWebServer({
1129
- accessToken: testToken,
1130
- uiDistDir: path.join(tmpDir, 'ui-dist'),
1131
- consoleDataOutputDir: null,
1132
- inTmuxDataDir: null,
1133
- dashboardDir: tmpDir,
1134
- port: 0,
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 is null', async () => {
1146
- const { server, tmpDir } = await (async () => {
1147
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
1148
- fs.writeFileSync(path.join(dir, 'tdpm.txt'), dashboardRaw);
1149
- const srv = await startWebServer({
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 { server, tmpDir } = await startWithDashboard();
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
- if (options.dashboardDir === null) {
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
- return { repository: new ProcHostMetricsRepository(procDirectory, sleep) };
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;