the-android-mcp 3.13.0 → 3.14.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.
@@ -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;AA6mPzC,wBAAgB,gBAAgB,CAAC,OAAO,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,IAAI,CAAC,MAAM,CA4B5F;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;AAuhQzC,wBAAgB,gBAAgB,CAAC,OAAO,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,IAAI,CAAC,MAAM,CA4B5F;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
@@ -2355,6 +2355,41 @@ function runRunbookCampaign(body) {
2355
2355
  updateHint: UPDATE_HINT,
2356
2356
  };
2357
2357
  }
2358
+ function planRunbookCampaign(body) {
2359
+ const runbookName = typeof body.name === 'string' ? body.name.trim() : '';
2360
+ if (!runbookName) {
2361
+ throw new Error('runbook name is required');
2362
+ }
2363
+ const requestedDeviceIds = Array.isArray(body.deviceIds)
2364
+ ? body.deviceIds.filter((value) => typeof value === 'string' && value.trim().length > 0)
2365
+ : [];
2366
+ const connectedDeviceIds = (0, adb_js_1.getConnectedDevices)().map(device => device.id);
2367
+ const targetDeviceIds = requestedDeviceIds.length > 0 ? requestedDeviceIds : connectedDeviceIds;
2368
+ if (targetDeviceIds.length === 0) {
2369
+ throw new Error('no connected devices for campaign');
2370
+ }
2371
+ const laneHints = targetDeviceIds.map(deviceId => ({
2372
+ deviceId,
2373
+ laneId: `device:${deviceId}`,
2374
+ queueDepth: lanes[`device:${deviceId}`]?.queue.length ?? 0,
2375
+ }));
2376
+ return {
2377
+ ok: true,
2378
+ name: runbookName,
2379
+ targetCount: targetDeviceIds.length,
2380
+ targets: laneHints,
2381
+ payloadDefaults: {
2382
+ packageName: typeof body.packageName === 'string' ? body.packageName : 'com.android.chrome',
2383
+ waitForReadyMs: clampInt(body.waitForReadyMs, 900, 200, 10000),
2384
+ },
2385
+ recommendations: [
2386
+ 'Start with continueOnError=true for multi-device execution.',
2387
+ 'Use queue snapshots before campaign for fast rollback.',
2388
+ 'Run control-room after campaign to verify stability score.',
2389
+ ],
2390
+ updateHint: UPDATE_HINT,
2391
+ };
2392
+ }
2358
2393
  function seedRecommendedAlertRules(body) {
2359
2394
  const totals = laneTotals();
2360
2395
  const avgQueuePerLane = totals.laneCount > 0 ? Math.ceil(totals.queueDepth / totals.laneCount) : totals.queueDepth;
@@ -2471,6 +2506,36 @@ function buildControlRoomPayload(host, port) {
2471
2506
  updateHint: UPDATE_HINT,
2472
2507
  };
2473
2508
  }
2509
+ function previewAutoHeal(body) {
2510
+ const laneId = typeof body.laneId === 'string' && body.laneId.trim().length > 0 ? body.laneId.trim() : undefined;
2511
+ const laneTargets = laneId ? [laneId] : Object.keys(lanes);
2512
+ const resumableLanes = laneTargets.filter(id => Boolean(lanes[id]?.paused));
2513
+ const retryFailedLimit = clampInt(body.retryFailedLimit, opsPolicy.autoRetryFailedLimit, 0, 500);
2514
+ const retryCandidates = jobs
2515
+ .filter(job => job.status === 'failed')
2516
+ .filter(job => !laneId || job.laneId === laneId)
2517
+ .slice(0, retryFailedLimit);
2518
+ const openIncidentCount = alertIncidents.filter(item => !item.acknowledgedAt).length;
2519
+ const runRecoverRunbook = body.runRecoverRunbook === true;
2520
+ return {
2521
+ ok: true,
2522
+ laneId,
2523
+ laneTargetCount: laneTargets.length,
2524
+ openIncidentCount,
2525
+ resumableLaneCount: resumableLanes.length,
2526
+ resumableLanes,
2527
+ retryFailedLimit,
2528
+ retryCandidateCount: retryCandidates.length,
2529
+ retryCandidates: retryCandidates.map(job => ({
2530
+ id: job.id,
2531
+ type: job.type,
2532
+ laneId: job.laneId,
2533
+ error: job.error,
2534
+ })),
2535
+ wouldRunRecoverRunbook: runRecoverRunbook,
2536
+ updateHint: UPDATE_HINT,
2537
+ };
2538
+ }
2474
2539
  function runAutoHeal(body) {
2475
2540
  const laneId = typeof body.laneId === 'string' && body.laneId.trim().length > 0 ? body.laneId.trim() : undefined;
2476
2541
  const ackOpenIncidents = body.ackOpenIncidents !== false;
@@ -2548,6 +2613,64 @@ function runAutoHeal(body) {
2548
2613
  updateHint: UPDATE_HINT,
2549
2614
  };
2550
2615
  }
2616
+ function runOpsStabilize(body, host, port) {
2617
+ const seeded = seedRecommendedAlertRules(body);
2618
+ const before = buildControlRoomPayload(host, port);
2619
+ const alertsCheck = evaluateAlertRulesNow();
2620
+ const healed = runAutoHeal({
2621
+ ...body,
2622
+ ackOpenIncidents: body.ackOpenIncidents !== false,
2623
+ resumePausedLanes: body.resumePausedLanes !== false,
2624
+ runRecoverRunbook: body.runRecoverRunbook !== false,
2625
+ });
2626
+ const after = buildControlRoomPayload(host, port);
2627
+ pushEvent('ops-stabilize', 'One-click stabilize workflow executed', {
2628
+ beforeSeverity: before.severity,
2629
+ afterSeverity: after.severity,
2630
+ });
2631
+ return {
2632
+ ok: true,
2633
+ seeded,
2634
+ alertsCheck,
2635
+ healed,
2636
+ before,
2637
+ after,
2638
+ updateHint: UPDATE_HINT,
2639
+ };
2640
+ }
2641
+ function runWatchdog(body, host, port) {
2642
+ const controlBefore = buildControlRoomPayload(host, port);
2643
+ const diagnostics = buildDiagnosticsReport(host, port);
2644
+ const alertsCheck = evaluateAlertRulesNow();
2645
+ const autoHealOnAlert = body.autoHealOnAlert === true;
2646
+ let autoHealResult;
2647
+ if (autoHealOnAlert && (controlBefore.severity === 'red' || controlBefore.severity === 'amber')) {
2648
+ autoHealResult = runAutoHeal({
2649
+ laneId: typeof body.laneId === 'string' ? body.laneId : undefined,
2650
+ retryFailedLimit: clampInt(body.retryFailedLimit, 12, 0, 500),
2651
+ runRecoverRunbook: true,
2652
+ maxRunbooks: clampInt(body.maxRunbooks, 2, 1, 50),
2653
+ ackOpenIncidents: true,
2654
+ resumePausedLanes: true,
2655
+ });
2656
+ }
2657
+ const controlAfter = buildControlRoomPayload(host, port);
2658
+ pushEvent('ops-watchdog', 'Watchdog check executed', {
2659
+ beforeSeverity: controlBefore.severity,
2660
+ afterSeverity: controlAfter.severity,
2661
+ autoHealTriggered: Boolean(autoHealResult),
2662
+ });
2663
+ return {
2664
+ ok: true,
2665
+ controlBefore,
2666
+ alertsCheck,
2667
+ diagnostics,
2668
+ autoHealTriggered: Boolean(autoHealResult),
2669
+ autoHealResult,
2670
+ controlAfter,
2671
+ updateHint: UPDATE_HINT,
2672
+ };
2673
+ }
2551
2674
  function buildAuditExport(host, port) {
2552
2675
  return {
2553
2676
  ok: true,
@@ -2908,6 +3031,63 @@ function applyQueueSnapshot(nameRaw, body) {
2908
3031
  updateHint: UPDATE_HINT,
2909
3032
  };
2910
3033
  }
3034
+ function diffQueueSnapshot(nameRaw) {
3035
+ const name = nameRaw.trim();
3036
+ if (!name) {
3037
+ throw new Error('snapshot name is required');
3038
+ }
3039
+ const snapshot = queueSnapshots[name];
3040
+ if (!snapshot) {
3041
+ throw new Error(`snapshot '${name}' not found`);
3042
+ }
3043
+ const currentExport = exportQueueState();
3044
+ const currentStats = queueSnapshotStats(currentExport);
3045
+ const storedStats = queueSnapshotStats(snapshot.payload);
3046
+ const storedMap = new Map();
3047
+ for (const lane of storedStats.lanes) {
3048
+ const id = typeof lane.laneId === 'string' ? lane.laneId : '';
3049
+ if (!id) {
3050
+ continue;
3051
+ }
3052
+ const queued = Array.isArray(lane.queued) ? lane.queued.length : 0;
3053
+ storedMap.set(id, queued);
3054
+ }
3055
+ const currentMap = new Map();
3056
+ for (const lane of currentStats.lanes) {
3057
+ const id = typeof lane.laneId === 'string' ? lane.laneId : '';
3058
+ if (!id) {
3059
+ continue;
3060
+ }
3061
+ const queued = Array.isArray(lane.queued) ? lane.queued.length : 0;
3062
+ currentMap.set(id, queued);
3063
+ }
3064
+ const laneDiffs = [];
3065
+ const laneIds = new Set([...storedMap.keys(), ...currentMap.keys()]);
3066
+ for (const laneId of laneIds) {
3067
+ const snapshotQueued = storedMap.get(laneId) ?? 0;
3068
+ const currentQueued = currentMap.get(laneId) ?? 0;
3069
+ laneDiffs.push({
3070
+ laneId,
3071
+ snapshotQueued,
3072
+ currentQueued,
3073
+ delta: currentQueued - snapshotQueued,
3074
+ });
3075
+ }
3076
+ return {
3077
+ ok: true,
3078
+ name,
3079
+ snapshotCapturedAt: snapshot.capturedAt,
3080
+ summary: {
3081
+ snapshotLaneCount: storedStats.laneCount,
3082
+ currentLaneCount: currentStats.laneCount,
3083
+ snapshotQueuedCount: storedStats.queuedCount,
3084
+ currentQueuedCount: currentStats.queuedCount,
3085
+ queuedDelta: currentStats.queuedCount - storedStats.queuedCount,
3086
+ },
3087
+ laneDiffs: laneDiffs.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)).slice(0, 40),
3088
+ updateHint: UPDATE_HINT,
3089
+ };
3090
+ }
2911
3091
  function schedulesList() {
2912
3092
  return Object.values(schedules)
2913
3093
  .sort((a, b) => a.id - b.id)
@@ -3776,6 +3956,27 @@ async function handleApi(request, response, context) {
3776
3956
  });
3777
3957
  return;
3778
3958
  }
3959
+ if (method === 'POST' && pathname === '/api/ops/auto-heal/preview') {
3960
+ await withMetric('ops-auto-heal-preview', async () => {
3961
+ const body = await readJsonBody(request);
3962
+ sendJson(response, 200, previewAutoHeal(body));
3963
+ });
3964
+ return;
3965
+ }
3966
+ if (method === 'POST' && pathname === '/api/ops/stabilize') {
3967
+ await withMetric('ops-stabilize', async () => {
3968
+ const body = await readJsonBody(request);
3969
+ sendJson(response, 200, runOpsStabilize(body, context.host, context.port));
3970
+ });
3971
+ return;
3972
+ }
3973
+ if (method === 'POST' && pathname === '/api/ops/watchdog/run') {
3974
+ await withMetric('ops-watchdog', async () => {
3975
+ const body = await readJsonBody(request);
3976
+ sendJson(response, 200, runWatchdog(body, context.host, context.port));
3977
+ });
3978
+ return;
3979
+ }
3779
3980
  if (method === 'GET' && pathname === '/api/diagnostics/report') {
3780
3981
  await withMetric('diagnostics-report', () => {
3781
3982
  sendJson(response, 200, buildDiagnosticsReport(context.host, context.port));
@@ -4060,6 +4261,20 @@ async function handleApi(request, response, context) {
4060
4261
  });
4061
4262
  return;
4062
4263
  }
4264
+ if (method === 'POST' && pathname === '/api/queue/snapshots/diff') {
4265
+ await withMetric('queue-snapshots-diff', async () => {
4266
+ const body = await readJsonBody(request);
4267
+ const name = typeof body.name === 'string' ? body.name : '';
4268
+ try {
4269
+ sendJson(response, 200, diffQueueSnapshot(name));
4270
+ }
4271
+ catch (error) {
4272
+ const message = error instanceof Error ? error.message : String(error);
4273
+ sendJson(response, 400, { error: message });
4274
+ }
4275
+ });
4276
+ return;
4277
+ }
4063
4278
  if (method === 'POST' && pathname === '/api/lanes/pause-all') {
4064
4279
  await withMetric('lanes-pause-all', () => {
4065
4280
  const changed = setAllLanesPaused(true);
@@ -4748,6 +4963,20 @@ async function handleApi(request, response, context) {
4748
4963
  });
4749
4964
  return;
4750
4965
  }
4966
+ if (method === 'POST' && pathname === '/api/runbook/campaign/plan') {
4967
+ await withMetric('runbook-campaign-plan', async () => {
4968
+ const body = await readJsonBody(request);
4969
+ try {
4970
+ const result = planRunbookCampaign(body);
4971
+ sendJson(response, 200, result);
4972
+ }
4973
+ catch (error) {
4974
+ const message = error instanceof Error ? error.message : String(error);
4975
+ sendJson(response, 400, { error: message });
4976
+ }
4977
+ });
4978
+ return;
4979
+ }
4751
4980
  if (method === 'GET' && pathname === '/api/runbook/catalog') {
4752
4981
  await withMetric('runbook-catalog', () => {
4753
4982
  sendJson(response, 200, {
@@ -5329,20 +5558,36 @@ https://developer.android.com</textarea>
5329
5558
  <button class="p" id="control-room-btn">Load control room</button>
5330
5559
  <button class="w" id="auto-heal-btn">Run auto-heal</button>
5331
5560
  </div>
5561
+ <div class="split">
5562
+ <input id="ops-lane-id" placeholder="lane id (optional)" />
5563
+ <input id="watchdog-auto-heal" placeholder="watchdog auto-heal: true/false" value="true" />
5564
+ </div>
5332
5565
  <div class="split">
5333
5566
  <input id="auto-heal-retry-limit" type="number" min="0" value="15" />
5334
5567
  <input id="auto-heal-runbooks" type="number" min="1" value="2" />
5335
5568
  </div>
5569
+ <div class="split">
5570
+ <button class="s" id="auto-heal-preview-btn">Preview auto-heal</button>
5571
+ <button class="p" id="ops-stabilize-btn">Run stabilize</button>
5572
+ </div>
5573
+ <button class="s" id="watchdog-run-btn">Run watchdog cycle</button>
5574
+ <div id="control-room-panel" class="metrics"></div>
5336
5575
  <hr style="border:0;border-top:1px solid #2f4a61;" />
5337
5576
  <input id="queue-snapshot-name" placeholder="queue snapshot name" value="pre-change" />
5338
5577
  <div class="split">
5339
5578
  <button class="s" id="queue-snapshot-save-btn">Save queue snapshot</button>
5340
5579
  <button class="s" id="queue-snapshot-list-btn">List queue snapshots</button>
5341
5580
  </div>
5342
- <button class="p" id="queue-snapshot-apply-btn">Apply queue snapshot</button>
5581
+ <div class="split">
5582
+ <button class="p" id="queue-snapshot-apply-btn">Apply queue snapshot</button>
5583
+ <button class="s" id="queue-snapshot-diff-btn">Diff queue snapshot</button>
5584
+ </div>
5343
5585
  <textarea id="campaign-device-ids">[]</textarea>
5344
5586
  <div class="split">
5345
5587
  <button class="p" id="runbook-campaign-btn">Run runbook campaign</button>
5588
+ <button class="s" id="runbook-campaign-plan-btn">Plan campaign</button>
5589
+ </div>
5590
+ <div class="split">
5346
5591
  <button class="s" id="alert-seed-btn">Seed alert pack</button>
5347
5592
  </div>
5348
5593
  </article>
@@ -5440,8 +5685,11 @@ https://developer.android.com</textarea>
5440
5685
  const $cloneTargetDevice = document.getElementById('clone-target-device');
5441
5686
  const $incidentsKeepLatest = document.getElementById('incidents-keep-latest');
5442
5687
  const $incidentsOlderThan = document.getElementById('incidents-older-than');
5688
+ const $opsLaneId = document.getElementById('ops-lane-id');
5689
+ const $watchdogAutoHeal = document.getElementById('watchdog-auto-heal');
5443
5690
  const $autoHealRetryLimit = document.getElementById('auto-heal-retry-limit');
5444
5691
  const $autoHealRunbooks = document.getElementById('auto-heal-runbooks');
5692
+ const $controlRoomPanel = document.getElementById('control-room-panel');
5445
5693
  const $queueSnapshotName = document.getElementById('queue-snapshot-name');
5446
5694
  const $campaignDeviceIds = document.getElementById('campaign-device-ids');
5447
5695
 
@@ -5638,6 +5886,33 @@ https://developer.android.com</textarea>
5638
5886
  }
5639
5887
  }
5640
5888
 
5889
+ function renderControlRoom(payload) {
5890
+ if (!$controlRoomPanel) {
5891
+ return;
5892
+ }
5893
+ $controlRoomPanel.innerHTML = '';
5894
+ const severity = payload && payload.severity ? String(payload.severity) : 'unknown';
5895
+ const score = payload && typeof payload.score === 'number' ? payload.score : 0;
5896
+ const queuePressure = payload && typeof payload.queuePressure === 'number' ? payload.queuePressure : 0;
5897
+ const openIncidentCount = payload && typeof payload.openIncidentCount === 'number' ? payload.openIncidentCount : 0;
5898
+ const failedJobCount = payload && typeof payload.failedJobCount === 'number' ? payload.failedJobCount : 0;
5899
+ const top = document.createElement('div');
5900
+ top.className = 'item';
5901
+ top.innerHTML =
5902
+ '<div class="meta">control room</div>' +
5903
+ '<div>severity=' + severity + ' score=' + score + '</div>' +
5904
+ '<div>queuePressure=' + queuePressure + ' openIncidents=' + openIncidentCount + ' failedJobs=' + failedJobCount + '</div>';
5905
+ $controlRoomPanel.appendChild(top);
5906
+
5907
+ const recs = Array.isArray(payload && payload.recommendations) ? payload.recommendations : [];
5908
+ for (const rec of recs.slice(0, 6)) {
5909
+ const row = document.createElement('div');
5910
+ row.className = 'item';
5911
+ row.innerHTML = '<div>' + String(rec) + '</div>';
5912
+ $controlRoomPanel.appendChild(row);
5913
+ }
5914
+ }
5915
+
5641
5916
  function renderSchedules(payload) {
5642
5917
  const entries = Array.isArray(payload.schedules) ? payload.schedules : [];
5643
5918
  $schedules.innerHTML = '';
@@ -5823,6 +6098,25 @@ https://developer.android.com</textarea>
5823
6098
  };
5824
6099
  }
5825
6100
 
6101
+ function selectedLaneId() {
6102
+ const explicit = ($opsLaneId.value || '').trim();
6103
+ if (explicit) {
6104
+ return explicit;
6105
+ }
6106
+ return selectedDeviceId() ? 'device:' + selectedDeviceId() : undefined;
6107
+ }
6108
+
6109
+ function asBoolean(value, fallback) {
6110
+ const text = String(value || '').trim().toLowerCase();
6111
+ if (text === 'true' || text === '1' || text === 'yes' || text === 'y') {
6112
+ return true;
6113
+ }
6114
+ if (text === 'false' || text === '0' || text === 'no' || text === 'n') {
6115
+ return false;
6116
+ }
6117
+ return fallback;
6118
+ }
6119
+
5826
6120
  async function refreshJobsAndLanes() {
5827
6121
  const jobsPayload = await api('/api/jobs');
5828
6122
  renderJobs(jobsPayload);
@@ -5863,6 +6157,9 @@ https://developer.android.com</textarea>
5863
6157
  if (!$cloneSourceLane.value || !$cloneSourceLane.value.trim()) {
5864
6158
  $cloneSourceLane.value = 'device:' + state.deviceId;
5865
6159
  }
6160
+ if (!$opsLaneId.value || !$opsLaneId.value.trim()) {
6161
+ $opsLaneId.value = 'device:' + state.deviceId;
6162
+ }
5866
6163
  if (!$campaignDeviceIds.value || !$campaignDeviceIds.value.trim()) {
5867
6164
  $campaignDeviceIds.value = JSON.stringify([state.deviceId], null, 2);
5868
6165
  }
@@ -6711,13 +7008,14 @@ https://developer.android.com</textarea>
6711
7008
 
6712
7009
  async function loadControlRoomUi() {
6713
7010
  const result = await api('/api/ops/control-room');
7011
+ renderControlRoom(result);
6714
7012
  renderOutput(result);
6715
7013
  setMessage('Control room loaded', false);
6716
7014
  }
6717
7015
 
6718
7016
  async function runAutoHealUi() {
6719
7017
  const result = await api('/api/ops/auto-heal', 'POST', {
6720
- laneId: selectedDeviceId() ? 'device:' + selectedDeviceId() : undefined,
7018
+ laneId: selectedLaneId(),
6721
7019
  ackOpenIncidents: true,
6722
7020
  resumePausedLanes: true,
6723
7021
  retryFailedLimit: Number($autoHealRetryLimit.value || '15'),
@@ -6728,6 +7026,17 @@ https://developer.android.com</textarea>
6728
7026
  setMessage('Auto-heal executed', false);
6729
7027
  await refreshJobsAndLanes();
6730
7028
  await loadAlertsUi();
7029
+ await loadControlRoomUi();
7030
+ }
7031
+
7032
+ async function previewAutoHealUi() {
7033
+ const result = await api('/api/ops/auto-heal/preview', 'POST', {
7034
+ laneId: selectedLaneId(),
7035
+ retryFailedLimit: Number($autoHealRetryLimit.value || '15'),
7036
+ runRecoverRunbook: true,
7037
+ });
7038
+ renderOutput(result);
7039
+ setMessage('Auto-heal preview ready', false);
6731
7040
  }
6732
7041
 
6733
7042
  async function saveQueueSnapshotUi() {
@@ -6764,6 +7073,16 @@ https://developer.android.com</textarea>
6764
7073
  await refreshJobsAndLanes();
6765
7074
  }
6766
7075
 
7076
+ async function diffQueueSnapshotUi() {
7077
+ const name = ($queueSnapshotName.value || '').trim();
7078
+ if (!name) {
7079
+ throw new Error('queue snapshot name required');
7080
+ }
7081
+ const result = await api('/api/queue/snapshots/diff', 'POST', { name });
7082
+ renderOutput(result);
7083
+ setMessage('Queue snapshot diff loaded', false);
7084
+ }
7085
+
6767
7086
  async function runRunbookCampaignUi() {
6768
7087
  let deviceIds = [];
6769
7088
  const raw = ($campaignDeviceIds.value || '').trim();
@@ -6798,6 +7117,36 @@ https://developer.android.com</textarea>
6798
7117
  await refreshJobsAndLanes();
6799
7118
  }
6800
7119
 
7120
+ async function planRunbookCampaignUi() {
7121
+ let deviceIds = [];
7122
+ const raw = ($campaignDeviceIds.value || '').trim();
7123
+ if (raw) {
7124
+ if (raw.startsWith('[')) {
7125
+ try {
7126
+ const parsed = JSON.parse(raw);
7127
+ if (Array.isArray(parsed)) {
7128
+ deviceIds = parsed.filter(function (value) { return typeof value === 'string' && value.trim().length > 0; });
7129
+ }
7130
+ } catch (error) {
7131
+ throw new Error('campaign devices must be JSON array');
7132
+ }
7133
+ } else {
7134
+ deviceIds = raw
7135
+ .split('\n')
7136
+ .map(function (line) { return line.trim(); })
7137
+ .filter(function (line) { return line.length > 0; });
7138
+ }
7139
+ }
7140
+ const result = await api('/api/runbook/campaign/plan', 'POST', {
7141
+ name: $runbookName.value || 'recover-lane',
7142
+ deviceIds,
7143
+ packageName: 'com.android.chrome',
7144
+ waitForReadyMs: 900,
7145
+ });
7146
+ renderOutput(result);
7147
+ setMessage('Runbook campaign plan ready', false);
7148
+ }
7149
+
6801
7150
  async function seedAlertRulesUi() {
6802
7151
  const result = await api('/api/alerts/rules/seed', 'POST', {
6803
7152
  cooldownMs: Number($alertCooldown.value || '120000'),
@@ -6808,6 +7157,42 @@ https://developer.android.com</textarea>
6808
7157
  await loadAlertsUi();
6809
7158
  }
6810
7159
 
7160
+ async function runOpsStabilizeUi() {
7161
+ const result = await api('/api/ops/stabilize', 'POST', {
7162
+ laneId: selectedLaneId(),
7163
+ retryFailedLimit: Number($autoHealRetryLimit.value || '15'),
7164
+ maxRunbooks: Number($autoHealRunbooks.value || '2'),
7165
+ runRecoverRunbook: true,
7166
+ ackOpenIncidents: true,
7167
+ resumePausedLanes: true,
7168
+ cooldownMs: Number($alertCooldown.value || '120000'),
7169
+ queueDepthThreshold: Number($alertThreshold.value || '10'),
7170
+ });
7171
+ renderOutput(result);
7172
+ setMessage('Stabilize workflow completed', false);
7173
+ await refreshJobsAndLanes();
7174
+ await loadAlertsUi();
7175
+ if (result.after) {
7176
+ renderControlRoom(result.after);
7177
+ }
7178
+ }
7179
+
7180
+ async function runWatchdogUi() {
7181
+ const result = await api('/api/ops/watchdog/run', 'POST', {
7182
+ laneId: selectedLaneId(),
7183
+ autoHealOnAlert: asBoolean($watchdogAutoHeal.value, true),
7184
+ retryFailedLimit: Number($autoHealRetryLimit.value || '15'),
7185
+ maxRunbooks: Number($autoHealRunbooks.value || '2'),
7186
+ });
7187
+ renderOutput(result);
7188
+ setMessage('Watchdog cycle completed', false);
7189
+ if (result.controlAfter) {
7190
+ renderControlRoom(result.controlAfter);
7191
+ }
7192
+ await refreshJobsAndLanes();
7193
+ await loadAlertsUi();
7194
+ }
7195
+
6811
7196
  document.getElementById('open-url-btn').addEventListener('click', async function () {
6812
7197
  try { await openUrl($urlInput.value); } catch (error) { setMessage(String(error), true); }
6813
7198
  });
@@ -7018,6 +7403,15 @@ https://developer.android.com</textarea>
7018
7403
  document.getElementById('auto-heal-btn').addEventListener('click', async function () {
7019
7404
  try { await runAutoHealUi(); } catch (error) { setMessage(String(error), true); }
7020
7405
  });
7406
+ document.getElementById('auto-heal-preview-btn').addEventListener('click', async function () {
7407
+ try { await previewAutoHealUi(); } catch (error) { setMessage(String(error), true); }
7408
+ });
7409
+ document.getElementById('ops-stabilize-btn').addEventListener('click', async function () {
7410
+ try { await runOpsStabilizeUi(); } catch (error) { setMessage(String(error), true); }
7411
+ });
7412
+ document.getElementById('watchdog-run-btn').addEventListener('click', async function () {
7413
+ try { await runWatchdogUi(); } catch (error) { setMessage(String(error), true); }
7414
+ });
7021
7415
  document.getElementById('queue-snapshot-save-btn').addEventListener('click', async function () {
7022
7416
  try { await saveQueueSnapshotUi(); } catch (error) { setMessage(String(error), true); }
7023
7417
  });
@@ -7027,9 +7421,15 @@ https://developer.android.com</textarea>
7027
7421
  document.getElementById('queue-snapshot-apply-btn').addEventListener('click', async function () {
7028
7422
  try { await applyQueueSnapshotUi(); } catch (error) { setMessage(String(error), true); }
7029
7423
  });
7424
+ document.getElementById('queue-snapshot-diff-btn').addEventListener('click', async function () {
7425
+ try { await diffQueueSnapshotUi(); } catch (error) { setMessage(String(error), true); }
7426
+ });
7030
7427
  document.getElementById('runbook-campaign-btn').addEventListener('click', async function () {
7031
7428
  try { await runRunbookCampaignUi(); } catch (error) { setMessage(String(error), true); }
7032
7429
  });
7430
+ document.getElementById('runbook-campaign-plan-btn').addEventListener('click', async function () {
7431
+ try { await planRunbookCampaignUi(); } catch (error) { setMessage(String(error), true); }
7432
+ });
7033
7433
  document.getElementById('alert-seed-btn').addEventListener('click', async function () {
7034
7434
  try { await seedAlertRulesUi(); } catch (error) { setMessage(String(error), true); }
7035
7435
  });
@@ -7077,6 +7477,7 @@ https://developer.android.com</textarea>
7077
7477
  await loadHeatmapUi();
7078
7478
  await loadPolicyUi();
7079
7479
  await loadOpsBoardUi();
7480
+ await loadControlRoomUi();
7080
7481
  await loadQueueBoardUi();
7081
7482
  await listSchedulesUi();
7082
7483
  await loadAlertsUi();
@@ -7096,6 +7497,8 @@ https://developer.android.com</textarea>
7096
7497
  renderRecorderSessions(recorderPayload);
7097
7498
  const opsBoardPayload = await api('/api/ops/board');
7098
7499
  renderOpsBoard(opsBoardPayload);
7500
+ const controlRoomPayload = await api('/api/ops/control-room');
7501
+ renderControlRoom(controlRoomPayload);
7099
7502
  const schedulesPayload = await api('/api/schedules');
7100
7503
  renderSchedules(schedulesPayload);
7101
7504
  const queueBoardPayload = await api('/api/board/queue');