the-android-mcp 3.19.0 → 3.20.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"web-ui.d.ts","sourceRoot":"","sources":["../src/web-ui.ts"],"names":[],"mappings":";AAKA,OAAO,IAAyC,MAAM,MAAM,CAAC;AAgB7D,eAAO,MAAM,mBAAmB,cAAc,CAAC;AAC/C,eAAO,MAAM,mBAAmB,QAAQ,CAAC;AAo+WzC,wBAAgB,gBAAgB,CAAC,OAAO,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,IAAI,CAAC,MAAM,CA6B5F;AAED,wBAAsB,qBAAqB,CAAC,OAAO,GAAE;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACV,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAiBhF"}
1
+ {"version":3,"file":"web-ui.d.ts","sourceRoot":"","sources":["../src/web-ui.ts"],"names":[],"mappings":";AAKA,OAAO,IAAyC,MAAM,MAAM,CAAC;AAgB7D,eAAO,MAAM,mBAAmB,cAAc,CAAC;AAC/C,eAAO,MAAM,mBAAmB,QAAQ,CAAC;AA65XzC,wBAAgB,gBAAgB,CAAC,OAAO,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,IAAI,CAAC,MAAM,CA8B5F;AAED,wBAAsB,qBAAqB,CAAC,OAAO,GAAE;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACV,GAAG,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAiBhF"}
package/dist/web-ui.js CHANGED
@@ -38,6 +38,7 @@ const WATCHDOG_PROFILES_FILE = path_1.default.join(APP_STATE_DIR, 'web-ui-watchd
38
38
  const OPS_MISSIONS_FILE = path_1.default.join(APP_STATE_DIR, 'web-ui-ops-missions.json');
39
39
  const OPS_MISSION_SCHEDULES_FILE = path_1.default.join(APP_STATE_DIR, 'web-ui-ops-mission-schedules.json');
40
40
  const OPS_MISSION_POLICY_FILE = path_1.default.join(APP_STATE_DIR, 'web-ui-ops-mission-policy.json');
41
+ const OPS_MISSION_RUNS_FILE = path_1.default.join(APP_STATE_DIR, 'web-ui-ops-mission-runs.json');
41
42
  const SNAPSHOT_KINDS = [
42
43
  'radio',
43
44
  'display',
@@ -93,6 +94,7 @@ let opsGatePolicy = {
93
94
  };
94
95
  const opsMissionRuns = [];
95
96
  let opsMissionRunSeq = 1;
97
+ let opsMissionRunsLoaded = false;
96
98
  const opsMissionSchedules = {};
97
99
  const opsMissionScheduleTimers = {};
98
100
  const opsMissionScheduleRunning = {};
@@ -1263,6 +1265,81 @@ function saveOpsMissions() {
1263
1265
  function listOpsMissions() {
1264
1266
  return Object.values(opsMissions).sort((a, b) => a.name.localeCompare(b.name));
1265
1267
  }
1268
+ function loadOpsMissionRunsFromDisk() {
1269
+ try {
1270
+ ensureAppStateDir();
1271
+ if (!fs_1.default.existsSync(OPS_MISSION_RUNS_FILE)) {
1272
+ fs_1.default.writeFileSync(OPS_MISSION_RUNS_FILE, JSON.stringify([], null, 2), 'utf8');
1273
+ opsMissionRuns.splice(0, opsMissionRuns.length);
1274
+ opsMissionRunSeq = 1;
1275
+ opsMissionRunsLoaded = true;
1276
+ return;
1277
+ }
1278
+ const raw = fs_1.default.readFileSync(OPS_MISSION_RUNS_FILE, 'utf8');
1279
+ const parsed = JSON.parse(raw);
1280
+ if (!Array.isArray(parsed)) {
1281
+ opsMissionRuns.splice(0, opsMissionRuns.length);
1282
+ opsMissionRunSeq = 1;
1283
+ opsMissionRunsLoaded = true;
1284
+ return;
1285
+ }
1286
+ const restored = [];
1287
+ let maxId = 0;
1288
+ for (const value of parsed) {
1289
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1290
+ continue;
1291
+ }
1292
+ const item = value;
1293
+ const id = Number(item.id);
1294
+ const mission = typeof item.mission === 'string' ? item.mission.trim() : '';
1295
+ const startedAt = typeof item.startedAt === 'string' ? item.startedAt : nowIso();
1296
+ const finishedAt = typeof item.finishedAt === 'string' ? item.finishedAt : startedAt;
1297
+ if (!Number.isFinite(id) || id <= 0 || !mission) {
1298
+ continue;
1299
+ }
1300
+ const steps = Array.isArray(item.steps)
1301
+ ? item.steps.filter(step => step && typeof step === 'object' && !Array.isArray(step))
1302
+ : [];
1303
+ const run = {
1304
+ id: Math.trunc(id),
1305
+ mission,
1306
+ startedAt,
1307
+ finishedAt,
1308
+ durationMs: clampInt(item.durationMs, 0, 0, 7 * 24 * 60 * 60 * 1000),
1309
+ ok: item.ok === true,
1310
+ steps,
1311
+ gate: item.gate && typeof item.gate === 'object' && !Array.isArray(item.gate)
1312
+ ? item.gate
1313
+ : undefined,
1314
+ error: typeof item.error === 'string' ? item.error : undefined,
1315
+ };
1316
+ restored.push(run);
1317
+ if (run.id > maxId) {
1318
+ maxId = run.id;
1319
+ }
1320
+ }
1321
+ restored.sort((a, b) => a.id - b.id);
1322
+ const limited = restored.slice(-MAX_JOBS);
1323
+ opsMissionRuns.splice(0, opsMissionRuns.length, ...limited);
1324
+ opsMissionRunSeq = Math.max(1, maxId + 1);
1325
+ opsMissionRunsLoaded = true;
1326
+ }
1327
+ catch {
1328
+ opsMissionRuns.splice(0, opsMissionRuns.length);
1329
+ opsMissionRunSeq = 1;
1330
+ opsMissionRunsLoaded = true;
1331
+ }
1332
+ }
1333
+ function saveOpsMissionRunsToDisk() {
1334
+ ensureAppStateDir();
1335
+ fs_1.default.writeFileSync(OPS_MISSION_RUNS_FILE, JSON.stringify(opsMissionRuns, null, 2), 'utf8');
1336
+ }
1337
+ function ensureOpsMissionRunsLoaded() {
1338
+ if (opsMissionRunsLoaded) {
1339
+ return;
1340
+ }
1341
+ loadOpsMissionRunsFromDisk();
1342
+ }
1266
1343
  function loadOpsMissionSchedules() {
1267
1344
  try {
1268
1345
  ensureAppStateDir();
@@ -1503,6 +1580,192 @@ function missionSchedulePolicyForecast(horizonMsRaw) {
1503
1580
  items: items.slice(0, 400),
1504
1581
  };
1505
1582
  }
1583
+ function buildOpsMissionScheduleStatus() {
1584
+ const policy = currentOpsMissionPolicyState();
1585
+ const nowMs = Date.now();
1586
+ const schedules = listOpsMissionSchedules().map(schedule => {
1587
+ const baseMsRaw = schedule.lastRunAt
1588
+ ? Date.parse(schedule.lastRunAt)
1589
+ : Date.parse(schedule.updatedAt || schedule.createdAt);
1590
+ const baseMs = Number.isFinite(baseMsRaw) ? baseMsRaw : nowMs;
1591
+ const nextRunMs = baseMs + Math.max(5000, schedule.everyMs);
1592
+ const due = schedule.active &&
1593
+ !policy.suspended &&
1594
+ nowMs >= nextRunMs &&
1595
+ opsMissionScheduleRunning[schedule.id] !== true;
1596
+ const overdueMs = due ? nowMs - nextRunMs : 0;
1597
+ return {
1598
+ id: schedule.id,
1599
+ name: schedule.name,
1600
+ mission: schedule.mission,
1601
+ active: schedule.active,
1602
+ running: opsMissionScheduleRunning[schedule.id] === true,
1603
+ nextRunAt: new Date(nextRunMs).toISOString(),
1604
+ due,
1605
+ overdueMs,
1606
+ runs: schedule.runs,
1607
+ failures: schedule.failures,
1608
+ lastRunAt: schedule.lastRunAt,
1609
+ lastError: schedule.lastError,
1610
+ laneId: schedule.laneId,
1611
+ deviceId: schedule.deviceId,
1612
+ };
1613
+ });
1614
+ const dueCount = schedules.filter(item => item.due).length;
1615
+ const activeCount = schedules.filter(item => item.active).length;
1616
+ return {
1617
+ ok: true,
1618
+ generatedAt: nowIso(),
1619
+ suspended: policy.suspended,
1620
+ suspendedReason: policy.suspendedReason,
1621
+ resumeAt: policy.resumeAt,
1622
+ total: schedules.length,
1623
+ activeCount,
1624
+ dueCount,
1625
+ schedules,
1626
+ };
1627
+ }
1628
+ function buildOpsMissionAnalytics(limitRaw) {
1629
+ ensureOpsMissionRunsLoaded();
1630
+ const limit = clampInt(limitRaw, 300, 1, 5000);
1631
+ const runs = opsMissionRuns.slice(-limit);
1632
+ const durations = runs
1633
+ .map(item => Number(item.durationMs))
1634
+ .filter(value => Number.isFinite(value) && value >= 0)
1635
+ .sort((a, b) => a - b);
1636
+ const passCount = runs.filter(item => item.ok).length;
1637
+ const failCount = runs.length - passCount;
1638
+ const avgDurationMs = durations.length > 0
1639
+ ? Math.round((durations.reduce((sum, value) => sum + value, 0) / durations.length) * 100) / 100
1640
+ : 0;
1641
+ const p95DurationMs = durations.length > 0
1642
+ ? durations[Math.max(0, Math.min(durations.length - 1, Math.ceil(durations.length * 0.95) - 1))]
1643
+ : 0;
1644
+ const byMission = new Map();
1645
+ const failureReasons = new Map();
1646
+ for (const run of runs) {
1647
+ const bucket = byMission.get(run.mission) || {
1648
+ mission: run.mission,
1649
+ total: 0,
1650
+ pass: 0,
1651
+ fail: 0,
1652
+ durationTotal: 0,
1653
+ durations: [],
1654
+ gatePass: 0,
1655
+ gateFail: 0,
1656
+ lastAt: undefined,
1657
+ lastOk: undefined,
1658
+ lastError: undefined,
1659
+ };
1660
+ bucket.total += 1;
1661
+ if (run.ok) {
1662
+ bucket.pass += 1;
1663
+ }
1664
+ else {
1665
+ bucket.fail += 1;
1666
+ const reason = run.error && run.error.trim().length > 0 ? run.error.trim() : 'unknown';
1667
+ failureReasons.set(reason, (failureReasons.get(reason) || 0) + 1);
1668
+ bucket.lastError = reason;
1669
+ }
1670
+ if (typeof run.durationMs === 'number' && Number.isFinite(run.durationMs) && run.durationMs >= 0) {
1671
+ bucket.durationTotal += run.durationMs;
1672
+ bucket.durations.push(run.durationMs);
1673
+ }
1674
+ const gate = run.gate && typeof run.gate === 'object' && !Array.isArray(run.gate)
1675
+ ? run.gate
1676
+ : undefined;
1677
+ if (gate) {
1678
+ if (gate.pass === true) {
1679
+ bucket.gatePass += 1;
1680
+ }
1681
+ else if (gate.pass === false) {
1682
+ bucket.gateFail += 1;
1683
+ }
1684
+ }
1685
+ bucket.lastAt = run.finishedAt;
1686
+ bucket.lastOk = run.ok;
1687
+ byMission.set(run.mission, bucket);
1688
+ }
1689
+ const missions = Array.from(byMission.values())
1690
+ .map(item => {
1691
+ const sorted = item.durations.slice().sort((a, b) => a - b);
1692
+ const avg = item.durations.length > 0 ? Math.round((item.durationTotal / item.durations.length) * 100) / 100 : 0;
1693
+ const p95 = sorted.length > 0
1694
+ ? sorted[Math.max(0, Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1))]
1695
+ : 0;
1696
+ return {
1697
+ mission: item.mission,
1698
+ total: item.total,
1699
+ pass: item.pass,
1700
+ fail: item.fail,
1701
+ passRate: item.total > 0 ? Math.round((item.pass / item.total) * 10000) / 100 : 0,
1702
+ avgDurationMs: avg,
1703
+ p95DurationMs: p95,
1704
+ gatePass: item.gatePass,
1705
+ gateFail: item.gateFail,
1706
+ lastAt: item.lastAt,
1707
+ lastOk: item.lastOk,
1708
+ lastError: item.lastError,
1709
+ };
1710
+ })
1711
+ .sort((a, b) => b.total - a.total);
1712
+ const reasons = Array.from(failureReasons.entries())
1713
+ .map(([reason, count]) => ({ reason, count }))
1714
+ .sort((a, b) => b.count - a.count)
1715
+ .slice(0, 20);
1716
+ return {
1717
+ ok: true,
1718
+ generatedAt: nowIso(),
1719
+ window: {
1720
+ requested: limit,
1721
+ actual: runs.length,
1722
+ },
1723
+ totals: {
1724
+ passCount,
1725
+ failCount,
1726
+ passRate: runs.length > 0 ? Math.round((passCount / runs.length) * 10000) / 100 : 0,
1727
+ avgDurationMs,
1728
+ p95DurationMs,
1729
+ },
1730
+ missions,
1731
+ failureReasons: reasons,
1732
+ recentRuns: runs.slice(-30).reverse(),
1733
+ };
1734
+ }
1735
+ function runDueOpsMissionSchedules(maxItemsRaw) {
1736
+ const maxItems = clampInt(maxItemsRaw, 5, 1, 200);
1737
+ const statusBefore = buildOpsMissionScheduleStatus();
1738
+ if (statusBefore.suspended === true) {
1739
+ return {
1740
+ ok: false,
1741
+ skipped: true,
1742
+ reason: 'policy-suspended',
1743
+ statusBefore,
1744
+ };
1745
+ }
1746
+ const dueSchedules = Array.isArray(statusBefore.schedules)
1747
+ ? statusBefore.schedules
1748
+ .filter(item => item.due === true)
1749
+ .sort((a, b) => Number(b.overdueMs || 0) - Number(a.overdueMs || 0))
1750
+ : [];
1751
+ const selected = dueSchedules.slice(0, maxItems);
1752
+ const results = [];
1753
+ for (const item of selected) {
1754
+ const id = Number(item.id);
1755
+ if (!Number.isFinite(id) || id <= 0) {
1756
+ continue;
1757
+ }
1758
+ results.push(runOpsMissionScheduleTask(Math.trunc(id)));
1759
+ }
1760
+ return {
1761
+ ok: results.every(item => item.ok !== false),
1762
+ requested: maxItems,
1763
+ dueBefore: dueSchedules.length,
1764
+ executed: results.length,
1765
+ results,
1766
+ statusAfter: buildOpsMissionScheduleStatus(),
1767
+ };
1768
+ }
1506
1769
  function loadSchedules() {
1507
1770
  try {
1508
1771
  ensureAppStateDir();
@@ -3807,6 +4070,7 @@ function deleteOpsMission(nameRaw) {
3807
4070
  return true;
3808
4071
  }
3809
4072
  function listOpsMissionRuns(limitRaw) {
4073
+ ensureOpsMissionRunsLoaded();
3810
4074
  const limit = clampInt(limitRaw, 120, 1, 1000);
3811
4075
  const runs = opsMissionRuns.slice(-limit).reverse();
3812
4076
  return {
@@ -3816,6 +4080,7 @@ function listOpsMissionRuns(limitRaw) {
3816
4080
  };
3817
4081
  }
3818
4082
  function clearOpsMissionRuns(body) {
4083
+ ensureOpsMissionRunsLoaded();
3819
4084
  const keepLatest = clampInt(body.keepLatest, 0, 0, 1000);
3820
4085
  const before = opsMissionRuns.length;
3821
4086
  if (keepLatest === 0) {
@@ -3825,6 +4090,7 @@ function clearOpsMissionRuns(body) {
3825
4090
  opsMissionRuns.splice(0, before - keepLatest);
3826
4091
  }
3827
4092
  const removed = before - opsMissionRuns.length;
4093
+ saveOpsMissionRunsToDisk();
3828
4094
  pushEvent('ops-mission-runs-cleared', 'Ops mission run history pruned', { removed, keepLatest });
3829
4095
  return {
3830
4096
  ok: true,
@@ -4173,6 +4439,7 @@ function planOpsMission(nameRaw, body, host, port) {
4173
4439
  };
4174
4440
  }
4175
4441
  function runOpsMission(nameRaw, body, host, port) {
4442
+ ensureOpsMissionRunsLoaded();
4176
4443
  const mission = resolveOpsMission(nameRaw);
4177
4444
  const startedAt = nowIso();
4178
4445
  const startedMs = Date.now();
@@ -4282,6 +4549,7 @@ function runOpsMission(nameRaw, body, host, port) {
4282
4549
  if (opsMissionRuns.length > MAX_JOBS) {
4283
4550
  opsMissionRuns.splice(0, opsMissionRuns.length - MAX_JOBS);
4284
4551
  }
4552
+ saveOpsMissionRunsToDisk();
4285
4553
  pushEvent('ops-mission-run', 'Ops mission executed', {
4286
4554
  name: mission.name,
4287
4555
  ok,
@@ -4318,6 +4586,7 @@ function runOpsMission(nameRaw, body, host, port) {
4318
4586
  if (opsMissionRuns.length > MAX_JOBS) {
4319
4587
  opsMissionRuns.splice(0, opsMissionRuns.length - MAX_JOBS);
4320
4588
  }
4589
+ saveOpsMissionRunsToDisk();
4321
4590
  pushEvent('ops-mission-failed', 'Ops mission failed', {
4322
4591
  name: mission.name,
4323
4592
  error: message,
@@ -4336,6 +4605,8 @@ function runOpsMission(nameRaw, body, host, port) {
4336
4605
  }
4337
4606
  }
4338
4607
  function buildAuditExport(host, port) {
4608
+ const missionAnalytics = buildOpsMissionAnalytics(1000);
4609
+ const missionScheduleStatus = buildOpsMissionScheduleStatus();
4339
4610
  return {
4340
4611
  ok: true,
4341
4612
  exportedAt: nowIso(),
@@ -4354,6 +4625,8 @@ function buildAuditExport(host, port) {
4354
4625
  opsMissionRuns: opsMissionRuns.slice(-200),
4355
4626
  opsMissionSchedules: listOpsMissionSchedules(),
4356
4627
  opsMissionPolicy: currentOpsMissionPolicyState(),
4628
+ opsMissionAnalytics: missionAnalytics,
4629
+ opsMissionScheduleStatus: missionScheduleStatus,
4357
4630
  controlRoomHistory: listControlRoomHistory(200),
4358
4631
  schedules: schedulesList(),
4359
4632
  timeline: dashboardTimeline.slice(-300),
@@ -5303,6 +5576,7 @@ function setupSse(request, response) {
5303
5576
  });
5304
5577
  }
5305
5578
  function buildStatePayload(host, port) {
5579
+ const missionScheduleStatus = buildOpsMissionScheduleStatus();
5306
5580
  const devices = (0, adb_js_1.getConnectedDevices)();
5307
5581
  const totals = laneTotals();
5308
5582
  return {
@@ -5328,6 +5602,7 @@ function buildStatePayload(host, port) {
5328
5602
  opsMissionCount: Object.keys(opsMissions).length,
5329
5603
  opsMissionRunCount: opsMissionRuns.length,
5330
5604
  opsMissionScheduleCount: Object.keys(opsMissionSchedules).length,
5605
+ opsMissionDueCount: typeof missionScheduleStatus.dueCount === 'number' ? missionScheduleStatus.dueCount : 0,
5331
5606
  opsMissionPolicySuspended: currentOpsMissionPolicyState().suspended,
5332
5607
  opsMissionPolicyResumeAt: currentOpsMissionPolicyState().resumeAt,
5333
5608
  controlRoomHistoryCount: controlRoomHistory.length,
@@ -5366,6 +5641,8 @@ function buildDashboardPayload(host, port) {
5366
5641
  };
5367
5642
  }
5368
5643
  function buildOpsBoard(host, port) {
5644
+ const missionAnalytics = buildOpsMissionAnalytics(300);
5645
+ const missionScheduleStatus = buildOpsMissionScheduleStatus();
5369
5646
  const recentFailedJobs = jobs
5370
5647
  .filter(job => job.status === 'failed')
5371
5648
  .slice(0, 20)
@@ -5412,6 +5689,8 @@ function buildOpsBoard(host, port) {
5412
5689
  opsMissionRuns: opsMissionRuns.slice(-60),
5413
5690
  opsMissionSchedules: listOpsMissionSchedules(),
5414
5691
  opsMissionPolicy: currentOpsMissionPolicyState(),
5692
+ opsMissionAnalytics: missionAnalytics,
5693
+ opsMissionScheduleStatus: missionScheduleStatus,
5415
5694
  controlRoomHistory: listControlRoomHistory(80),
5416
5695
  updateHint: UPDATE_HINT,
5417
5696
  };
@@ -5433,6 +5712,8 @@ function buildMetricsPayload() {
5433
5712
  };
5434
5713
  }
5435
5714
  function buildSessionExport() {
5715
+ const missionAnalytics = buildOpsMissionAnalytics(500);
5716
+ const missionScheduleStatus = buildOpsMissionScheduleStatus();
5436
5717
  return {
5437
5718
  exportedAt: nowIso(),
5438
5719
  state: {
@@ -5465,6 +5746,8 @@ function buildSessionExport() {
5465
5746
  opsMissionRuns,
5466
5747
  opsMissionSchedules,
5467
5748
  opsMissionPolicy: currentOpsMissionPolicyState(),
5749
+ opsMissionAnalytics: missionAnalytics,
5750
+ opsMissionScheduleStatus: missionScheduleStatus,
5468
5751
  controlRoomHistory,
5469
5752
  lanes: lanesSummary(),
5470
5753
  jobs,
@@ -6008,6 +6291,19 @@ async function handleApi(request, response, context) {
6008
6291
  });
6009
6292
  return;
6010
6293
  }
6294
+ if (method === 'GET' && pathname === '/api/ops/missions/analytics') {
6295
+ await withMetric('ops-missions-analytics', () => {
6296
+ const limitRaw = Number(url.searchParams.get('limit') ?? '300');
6297
+ sendJson(response, 200, buildOpsMissionAnalytics(limitRaw));
6298
+ });
6299
+ return;
6300
+ }
6301
+ if (method === 'GET' && pathname === '/api/ops/missions/schedules/status') {
6302
+ await withMetric('ops-missions-schedules-status', () => {
6303
+ sendJson(response, 200, buildOpsMissionScheduleStatus());
6304
+ });
6305
+ return;
6306
+ }
6011
6307
  if (method === 'POST' && pathname === '/api/ops/missions/schedules/pause-all') {
6012
6308
  await withMetric('ops-missions-schedules-pause-all', () => {
6013
6309
  sendJson(response, 200, setAllOpsMissionSchedulesActive(false));
@@ -6027,6 +6323,13 @@ async function handleApi(request, response, context) {
6027
6323
  });
6028
6324
  return;
6029
6325
  }
6326
+ if (method === 'POST' && pathname === '/api/ops/missions/schedules/run-due') {
6327
+ await withMetric('ops-missions-schedules-run-due', async () => {
6328
+ const body = await readJsonBody(request);
6329
+ sendJson(response, 200, runDueOpsMissionSchedules(body.maxItems));
6330
+ });
6331
+ return;
6332
+ }
6030
6333
  if (method === 'POST' && pathname === '/api/ops/missions/schedules') {
6031
6334
  await withMetric('ops-missions-schedules-save', async () => {
6032
6335
  const body = await readJsonBody(request);
@@ -7912,6 +8215,16 @@ https://developer.android.com</textarea>
7912
8215
  </div>
7913
8216
  <div id="ops-mission-schedules-panel" class="metrics"></div>
7914
8217
  <div id="ops-mission-forecast-panel" class="metrics"></div>
8218
+ <div class="split">
8219
+ <input id="ops-mission-analytics-limit" type="number" min="1" value="300" />
8220
+ <button class="s" id="ops-mission-analytics-btn">Load mission analytics</button>
8221
+ </div>
8222
+ <div class="split">
8223
+ <button class="s" id="ops-mission-schedules-status-btn">Load schedule status</button>
8224
+ <button class="p" id="ops-mission-run-due-btn">Run due schedules now</button>
8225
+ </div>
8226
+ <div id="ops-mission-analytics-panel" class="metrics"></div>
8227
+ <div id="ops-mission-schedule-status-panel" class="metrics"></div>
7915
8228
  <hr style="border:0;border-top:1px solid #2f4a61;" />
7916
8229
  <div class="split">
7917
8230
  <select id="ops-mission-policy-block">
@@ -8068,6 +8381,9 @@ https://developer.android.com</textarea>
8068
8381
  const $opsMissionSchedulesFactor = document.getElementById('ops-mission-schedules-factor');
8069
8382
  const $opsMissionForecastHorizonMs = document.getElementById('ops-mission-forecast-horizon-ms');
8070
8383
  const $opsMissionForecastPanel = document.getElementById('ops-mission-forecast-panel');
8384
+ const $opsMissionAnalyticsLimit = document.getElementById('ops-mission-analytics-limit');
8385
+ const $opsMissionAnalyticsPanel = document.getElementById('ops-mission-analytics-panel');
8386
+ const $opsMissionScheduleStatusPanel = document.getElementById('ops-mission-schedule-status-panel');
8071
8387
  const $opsMissionPolicyBlock = document.getElementById('ops-mission-policy-block');
8072
8388
  const $opsMissionPolicyMaxFailures = document.getElementById('ops-mission-policy-max-failures');
8073
8389
  const $opsMissionPolicyCooldownMs = document.getElementById('ops-mission-policy-cooldown-ms');
@@ -8521,6 +8837,63 @@ https://developer.android.com</textarea>
8521
8837
  $opsMissionPolicyPanel.appendChild(row);
8522
8838
  }
8523
8839
 
8840
+ function renderOpsMissionAnalytics(payload) {
8841
+ if (!$opsMissionAnalyticsPanel) {
8842
+ return;
8843
+ }
8844
+ const totals = payload && payload.totals ? payload.totals : {};
8845
+ const missions = Array.isArray(payload && payload.missions) ? payload.missions : [];
8846
+ const reasons = Array.isArray(payload && payload.failureReasons) ? payload.failureReasons : [];
8847
+ $opsMissionAnalyticsPanel.innerHTML = '';
8848
+ const head = document.createElement('div');
8849
+ head.className = 'item';
8850
+ head.innerHTML =
8851
+ '<div class="meta">mission analytics</div>' +
8852
+ '<div>pass=' + (totals.passCount || 0) + ' fail=' + (totals.failCount || 0) + ' passRate=' + (totals.passRate || 0) + '%</div>' +
8853
+ '<div>avg=' + (totals.avgDurationMs || 0) + 'ms p95=' + (totals.p95DurationMs || 0) + 'ms</div>';
8854
+ $opsMissionAnalyticsPanel.appendChild(head);
8855
+
8856
+ for (const item of missions.slice(0, 12)) {
8857
+ const row = document.createElement('div');
8858
+ row.className = 'item';
8859
+ row.innerHTML =
8860
+ '<div class="meta">' + item.mission + '</div>' +
8861
+ '<div>total=' + item.total + ' pass=' + item.pass + ' fail=' + item.fail + ' passRate=' + item.passRate + '%</div>' +
8862
+ '<div>avg=' + item.avgDurationMs + 'ms p95=' + item.p95DurationMs + 'ms gatePass=' + (item.gatePass || 0) + ' gateFail=' + (item.gateFail || 0) + '</div>';
8863
+ $opsMissionAnalyticsPanel.appendChild(row);
8864
+ }
8865
+ for (const reason of reasons.slice(0, 6)) {
8866
+ const row = document.createElement('div');
8867
+ row.className = 'item';
8868
+ row.innerHTML = '<div>fail reason: ' + reason.reason + ' (' + reason.count + ')</div>';
8869
+ $opsMissionAnalyticsPanel.appendChild(row);
8870
+ }
8871
+ }
8872
+
8873
+ function renderOpsMissionScheduleStatus(payload) {
8874
+ if (!$opsMissionScheduleStatusPanel) {
8875
+ return;
8876
+ }
8877
+ const schedules = Array.isArray(payload && payload.schedules) ? payload.schedules : [];
8878
+ $opsMissionScheduleStatusPanel.innerHTML = '';
8879
+ const head = document.createElement('div');
8880
+ head.className = 'item';
8881
+ head.innerHTML =
8882
+ '<div class="meta">schedule status</div>' +
8883
+ '<div>total=' + (payload.total || 0) + ' active=' + (payload.activeCount || 0) + ' due=' + (payload.dueCount || 0) + '</div>' +
8884
+ '<div>suspended=' + String(payload.suspended === true) + ' reason=' + (payload.suspendedReason || '-') + ' resumeAt=' + (payload.resumeAt || '-') + '</div>';
8885
+ $opsMissionScheduleStatusPanel.appendChild(head);
8886
+ for (const item of schedules.slice(0, 24)) {
8887
+ const row = document.createElement('div');
8888
+ row.className = 'item';
8889
+ row.innerHTML =
8890
+ '<div class="meta">#' + item.id + ' ' + item.name + ' [' + (item.active ? 'active' : 'idle') + (item.running ? ', running' : '') + ']</div>' +
8891
+ '<div>mission=' + item.mission + ' due=' + String(item.due === true) + ' overdueMs=' + (item.overdueMs || 0) + '</div>' +
8892
+ '<div>nextRunAt=' + (item.nextRunAt || '-') + ' runs=' + item.runs + ' failures=' + item.failures + '</div>';
8893
+ $opsMissionScheduleStatusPanel.appendChild(row);
8894
+ }
8895
+ }
8896
+
8524
8897
  function renderSchedules(payload) {
8525
8898
  const entries = Array.isArray(payload.schedules) ? payload.schedules : [];
8526
8899
  $schedules.innerHTML = '';
@@ -10300,6 +10673,36 @@ https://developer.android.com</textarea>
10300
10673
  setMessage('Mission schedule forecast ready', false);
10301
10674
  }
10302
10675
 
10676
+ async function loadOpsMissionAnalyticsUi() {
10677
+ const limit = Number($opsMissionAnalyticsLimit.value || '300');
10678
+ const result = await api('/api/ops/missions/analytics?limit=' + encodeURIComponent(String(limit)));
10679
+ renderOpsMissionAnalytics(result);
10680
+ renderOutput(result);
10681
+ setMessage('Mission analytics loaded', false);
10682
+ }
10683
+
10684
+ async function loadOpsMissionScheduleStatusUi() {
10685
+ const result = await api('/api/ops/missions/schedules/status');
10686
+ renderOpsMissionScheduleStatus(result);
10687
+ renderOutput(result);
10688
+ setMessage('Mission schedule status loaded', false);
10689
+ }
10690
+
10691
+ async function runDueOpsMissionSchedulesUi() {
10692
+ const result = await api('/api/ops/missions/schedules/run-due', 'POST', {
10693
+ maxItems: 10,
10694
+ });
10695
+ renderOutput(result);
10696
+ if (result.statusAfter) {
10697
+ renderOpsMissionScheduleStatus(result.statusAfter);
10698
+ } else {
10699
+ await loadOpsMissionScheduleStatusUi();
10700
+ }
10701
+ setMessage(result.ok ? 'Due schedules executed' : 'Due schedule run completed with issues', !result.ok);
10702
+ await listOpsMissionRunsUi();
10703
+ await listOpsMissionSchedulesUi();
10704
+ }
10705
+
10303
10706
  async function runOpsStabilizeUi() {
10304
10707
  const result = await api('/api/ops/stabilize', 'POST', {
10305
10708
  laneId: selectedLaneId(),
@@ -10687,6 +11090,15 @@ https://developer.android.com</textarea>
10687
11090
  document.getElementById('ops-mission-forecast-btn').addEventListener('click', async function () {
10688
11091
  try { await forecastOpsMissionSchedulesUi(); } catch (error) { setMessage(String(error), true); }
10689
11092
  });
11093
+ document.getElementById('ops-mission-analytics-btn').addEventListener('click', async function () {
11094
+ try { await loadOpsMissionAnalyticsUi(); } catch (error) { setMessage(String(error), true); }
11095
+ });
11096
+ document.getElementById('ops-mission-schedules-status-btn').addEventListener('click', async function () {
11097
+ try { await loadOpsMissionScheduleStatusUi(); } catch (error) { setMessage(String(error), true); }
11098
+ });
11099
+ document.getElementById('ops-mission-run-due-btn').addEventListener('click', async function () {
11100
+ try { await runDueOpsMissionSchedulesUi(); } catch (error) { setMessage(String(error), true); }
11101
+ });
10690
11102
  document.getElementById('ops-mission-policy-load-btn').addEventListener('click', async function () {
10691
11103
  try { await loadOpsMissionPolicyUi(); } catch (error) { setMessage(String(error), true); }
10692
11104
  });
@@ -10777,6 +11189,8 @@ https://developer.android.com</textarea>
10777
11189
  await listOpsMissionSchedulesUi();
10778
11190
  await loadOpsMissionPolicyUi();
10779
11191
  await forecastOpsMissionSchedulesUi();
11192
+ await loadOpsMissionAnalyticsUi();
11193
+ await loadOpsMissionScheduleStatusUi();
10780
11194
  renderOutput({ ok: true, message: 'UI ready', updateHint: '${UPDATE_HINT}' });
10781
11195
  setMessage('Ready.', false);
10782
11196
 
@@ -10803,6 +11217,10 @@ https://developer.android.com</textarea>
10803
11217
  renderOpsMissionSchedules(missionSchedulesPayload);
10804
11218
  const missionPolicyPayload = await api('/api/ops/missions/policy');
10805
11219
  renderOpsMissionPolicy(missionPolicyPayload);
11220
+ const missionStatusPayload = await api('/api/ops/missions/schedules/status');
11221
+ renderOpsMissionScheduleStatus(missionStatusPayload);
11222
+ const missionAnalyticsPayload = await api('/api/ops/missions/analytics?limit=200');
11223
+ renderOpsMissionAnalytics(missionAnalyticsPayload);
10806
11224
  const schedulesPayload = await api('/api/schedules');
10807
11225
  renderSchedules(schedulesPayload);
10808
11226
  const queueBoardPayload = await api('/api/board/queue');
@@ -10835,6 +11253,7 @@ https://developer.android.com</textarea>
10835
11253
  function startWebUiServer(options = {}) {
10836
11254
  const host = options.host ?? exports.DEFAULT_WEB_UI_HOST;
10837
11255
  const port = options.port ?? exports.DEFAULT_WEB_UI_PORT;
11256
+ ensureOpsMissionRunsLoaded();
10838
11257
  ensureSchedulesInitialized();
10839
11258
  ensureOpsMissionSchedulesInitialized();
10840
11259
  const server = http_1.default.createServer(async (request, response) => {