instar 0.23.15 → 0.23.17

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 (97) hide show
  1. package/dashboard/index.html +1293 -31
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +170 -5
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/core/AutoApprover.d.ts +63 -0
  6. package/dist/core/AutoApprover.d.ts.map +1 -0
  7. package/dist/core/AutoApprover.js +151 -0
  8. package/dist/core/AutoApprover.js.map +1 -0
  9. package/dist/core/CallbackRegistry.d.ts +67 -0
  10. package/dist/core/CallbackRegistry.d.ts.map +1 -0
  11. package/dist/core/CallbackRegistry.js +145 -0
  12. package/dist/core/CallbackRegistry.js.map +1 -0
  13. package/dist/core/PostUpdateMigrator.d.ts +11 -0
  14. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  15. package/dist/core/PostUpdateMigrator.js +69 -0
  16. package/dist/core/PostUpdateMigrator.js.map +1 -1
  17. package/dist/core/SessionManager.d.ts +49 -0
  18. package/dist/core/SessionManager.d.ts.map +1 -1
  19. package/dist/core/SessionManager.js +193 -7
  20. package/dist/core/SessionManager.js.map +1 -1
  21. package/dist/core/TopicResumeMap.d.ts +14 -2
  22. package/dist/core/TopicResumeMap.d.ts.map +1 -1
  23. package/dist/core/TopicResumeMap.js +36 -41
  24. package/dist/core/TopicResumeMap.js.map +1 -1
  25. package/dist/core/types.d.ts +30 -0
  26. package/dist/core/types.d.ts.map +1 -1
  27. package/dist/core/types.js.map +1 -1
  28. package/dist/data/http-hook-templates.js +10 -10
  29. package/dist/data/http-hook-templates.js.map +1 -1
  30. package/dist/lifeline/ServerSupervisor.d.ts +2 -0
  31. package/dist/lifeline/ServerSupervisor.d.ts.map +1 -1
  32. package/dist/lifeline/ServerSupervisor.js +4 -1
  33. package/dist/lifeline/ServerSupervisor.js.map +1 -1
  34. package/dist/memory/TopicMemory.d.ts +5 -1
  35. package/dist/memory/TopicMemory.d.ts.map +1 -1
  36. package/dist/memory/TopicMemory.js +29 -10
  37. package/dist/memory/TopicMemory.js.map +1 -1
  38. package/dist/memory/TopicSummarizer.d.ts +12 -1
  39. package/dist/memory/TopicSummarizer.d.ts.map +1 -1
  40. package/dist/memory/TopicSummarizer.js +28 -5
  41. package/dist/memory/TopicSummarizer.js.map +1 -1
  42. package/dist/messaging/TelegramAdapter.d.ts +63 -1
  43. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  44. package/dist/messaging/TelegramAdapter.js +349 -2
  45. package/dist/messaging/TelegramAdapter.js.map +1 -1
  46. package/dist/monitoring/InputClassifier.d.ts +68 -0
  47. package/dist/monitoring/InputClassifier.d.ts.map +1 -0
  48. package/dist/monitoring/InputClassifier.js +243 -0
  49. package/dist/monitoring/InputClassifier.js.map +1 -0
  50. package/dist/monitoring/PromptGate.d.ts +74 -0
  51. package/dist/monitoring/PromptGate.d.ts.map +1 -0
  52. package/dist/monitoring/PromptGate.js +294 -0
  53. package/dist/monitoring/PromptGate.js.map +1 -0
  54. package/dist/monitoring/SessionRecovery.d.ts +110 -0
  55. package/dist/monitoring/SessionRecovery.d.ts.map +1 -0
  56. package/dist/monitoring/SessionRecovery.js +432 -0
  57. package/dist/monitoring/SessionRecovery.js.map +1 -0
  58. package/dist/monitoring/crash-detector.d.ts +50 -0
  59. package/dist/monitoring/crash-detector.d.ts.map +1 -0
  60. package/dist/monitoring/crash-detector.js +224 -0
  61. package/dist/monitoring/crash-detector.js.map +1 -0
  62. package/dist/monitoring/jsonl-truncator.d.ts +44 -0
  63. package/dist/monitoring/jsonl-truncator.d.ts.map +1 -0
  64. package/dist/monitoring/jsonl-truncator.js +224 -0
  65. package/dist/monitoring/jsonl-truncator.js.map +1 -0
  66. package/dist/monitoring/stall-detector.d.ts +34 -0
  67. package/dist/monitoring/stall-detector.d.ts.map +1 -0
  68. package/dist/monitoring/stall-detector.js +151 -0
  69. package/dist/monitoring/stall-detector.js.map +1 -0
  70. package/dist/scheduler/JobScheduler.d.ts +8 -0
  71. package/dist/scheduler/JobScheduler.d.ts.map +1 -1
  72. package/dist/scheduler/JobScheduler.js +48 -2
  73. package/dist/scheduler/JobScheduler.js.map +1 -1
  74. package/dist/server/AgentServer.d.ts.map +1 -1
  75. package/dist/server/AgentServer.js +3 -1
  76. package/dist/server/AgentServer.js.map +1 -1
  77. package/dist/server/WebSocketManager.d.ts +2 -0
  78. package/dist/server/WebSocketManager.d.ts.map +1 -1
  79. package/dist/server/WebSocketManager.js +24 -3
  80. package/dist/server/WebSocketManager.js.map +1 -1
  81. package/dist/server/middleware.d.ts +5 -0
  82. package/dist/server/middleware.d.ts.map +1 -1
  83. package/dist/server/middleware.js +25 -2
  84. package/dist/server/middleware.js.map +1 -1
  85. package/dist/server/routes.d.ts.map +1 -1
  86. package/dist/server/routes.js +414 -8
  87. package/dist/server/routes.js.map +1 -1
  88. package/package.json +2 -1
  89. package/scripts/pre-push-gate.js +149 -0
  90. package/src/data/builtin-manifest.json +64 -64
  91. package/src/data/http-hook-templates.ts +10 -10
  92. package/src/templates/hooks/telegram-topic-context.sh +4 -4
  93. package/upgrades/0.23.10.md +19 -0
  94. package/upgrades/0.23.11.md +21 -0
  95. package/upgrades/0.23.15.md +14 -1
  96. package/upgrades/0.23.16.md +21 -0
  97. package/upgrades/0.23.17.md +23 -0
@@ -1478,6 +1478,443 @@
1478
1478
  color: var(--text);
1479
1479
  }
1480
1480
 
1481
+ /* ── Vital Signs Strip ────────────────────────────────── */
1482
+ .vital-signs {
1483
+ display: flex;
1484
+ align-items: center;
1485
+ gap: 12px;
1486
+ margin-left: auto;
1487
+ margin-right: 12px;
1488
+ font-size: 11px;
1489
+ color: var(--text-dim);
1490
+ }
1491
+
1492
+ .vital-signs .vital {
1493
+ display: flex;
1494
+ align-items: center;
1495
+ gap: 5px;
1496
+ cursor: pointer;
1497
+ padding: 2px 6px;
1498
+ border-radius: 4px;
1499
+ transition: background 0.15s;
1500
+ white-space: nowrap;
1501
+ }
1502
+
1503
+ .vital-signs .vital:hover {
1504
+ background: var(--bg-hover);
1505
+ }
1506
+
1507
+ .vital-signs .vital-dot {
1508
+ width: 6px;
1509
+ height: 6px;
1510
+ border-radius: 50%;
1511
+ flex-shrink: 0;
1512
+ }
1513
+
1514
+ .vital-signs .vital-icon {
1515
+ font-size: 10px;
1516
+ flex-shrink: 0;
1517
+ width: 12px;
1518
+ text-align: center;
1519
+ }
1520
+
1521
+ .vital-signs .vital-bar {
1522
+ width: 48px;
1523
+ height: 4px;
1524
+ background: var(--border);
1525
+ border-radius: 2px;
1526
+ overflow: hidden;
1527
+ }
1528
+
1529
+ .vital-signs .vital-bar-fill {
1530
+ height: 100%;
1531
+ border-radius: 2px;
1532
+ transition: width 0.5s, background 0.5s;
1533
+ }
1534
+
1535
+ .vital-signs .vital.warn { color: var(--orange); }
1536
+ .vital-signs .vital.crit { color: var(--red); }
1537
+ .vital-signs .vital-sep {
1538
+ width: 1px;
1539
+ height: 14px;
1540
+ background: var(--border);
1541
+ }
1542
+
1543
+ /* ── Jobs Tab ─────────────────────────────────────────── */
1544
+ .jobs-container {
1545
+ display: flex;
1546
+ grid-column: 1 / -1;
1547
+ overflow: hidden;
1548
+ background: var(--bg);
1549
+ }
1550
+
1551
+ .jobs-sidebar {
1552
+ width: 320px;
1553
+ min-width: 320px;
1554
+ border-right: 1px solid var(--border);
1555
+ background: var(--bg-panel);
1556
+ display: flex;
1557
+ flex-direction: column;
1558
+ overflow: hidden;
1559
+ }
1560
+
1561
+ .jobs-sidebar-header {
1562
+ padding: 14px 16px;
1563
+ border-bottom: 1px solid var(--border);
1564
+ display: flex;
1565
+ align-items: center;
1566
+ justify-content: space-between;
1567
+ }
1568
+
1569
+ .jobs-sidebar-header h2 {
1570
+ font-size: 14px;
1571
+ font-weight: 600;
1572
+ color: var(--text-bright);
1573
+ }
1574
+
1575
+ .jobs-filter-bar {
1576
+ display: flex;
1577
+ gap: 4px;
1578
+ padding: 8px 12px;
1579
+ border-bottom: 1px solid var(--border);
1580
+ flex-wrap: wrap;
1581
+ }
1582
+
1583
+ .jobs-filter-chip {
1584
+ font-size: 11px;
1585
+ padding: 2px 8px;
1586
+ border-radius: 10px;
1587
+ border: 1px solid var(--border);
1588
+ background: transparent;
1589
+ color: var(--text-dim);
1590
+ cursor: pointer;
1591
+ transition: all 0.15s;
1592
+ }
1593
+
1594
+ .jobs-filter-chip:hover {
1595
+ border-color: var(--text-dim);
1596
+ color: var(--text);
1597
+ }
1598
+
1599
+ .jobs-filter-chip.active {
1600
+ background: var(--accent-dim);
1601
+ border-color: var(--accent-dim);
1602
+ color: #000;
1603
+ }
1604
+
1605
+ .jobs-sort {
1606
+ font-size: 11px;
1607
+ background: var(--bg);
1608
+ color: var(--text-dim);
1609
+ border: 1px solid var(--border);
1610
+ border-radius: 4px;
1611
+ padding: 1px 4px;
1612
+ margin-left: auto;
1613
+ }
1614
+
1615
+ .jobs-list {
1616
+ flex: 1;
1617
+ overflow-y: auto;
1618
+ }
1619
+
1620
+ .job-item {
1621
+ padding: 10px 14px;
1622
+ border-bottom: 1px solid var(--border);
1623
+ cursor: pointer;
1624
+ transition: background 0.1s;
1625
+ }
1626
+
1627
+ .job-item:hover {
1628
+ background: var(--bg-hover);
1629
+ }
1630
+
1631
+ .job-item.active {
1632
+ background: var(--bg-active);
1633
+ border-left: 2px solid var(--accent);
1634
+ }
1635
+
1636
+ .job-item-top {
1637
+ display: flex;
1638
+ align-items: center;
1639
+ gap: 8px;
1640
+ }
1641
+
1642
+ .job-status-dot {
1643
+ width: 8px;
1644
+ height: 8px;
1645
+ border-radius: 50%;
1646
+ flex-shrink: 0;
1647
+ }
1648
+
1649
+ .job-status-dot.healthy { background: var(--accent); }
1650
+ .job-status-dot.warn { background: var(--orange); }
1651
+ .job-status-dot.failing { background: var(--red); }
1652
+ .job-status-dot.disabled { background: #444; }
1653
+ .job-status-dot.running { background: var(--blue); animation: pulse 1.5s infinite; }
1654
+
1655
+ .job-item-name {
1656
+ font-size: 13px;
1657
+ font-weight: 500;
1658
+ color: var(--text-bright);
1659
+ flex: 1;
1660
+ min-width: 0;
1661
+ overflow: hidden;
1662
+ text-overflow: ellipsis;
1663
+ white-space: nowrap;
1664
+ }
1665
+
1666
+ .job-failure-count {
1667
+ font-size: 11px;
1668
+ color: var(--red);
1669
+ font-weight: 600;
1670
+ }
1671
+
1672
+ .job-item-meta {
1673
+ margin-top: 3px;
1674
+ margin-left: 16px;
1675
+ font-size: 11px;
1676
+ color: var(--text-dim);
1677
+ display: flex;
1678
+ gap: 6px;
1679
+ align-items: center;
1680
+ flex-wrap: wrap;
1681
+ }
1682
+
1683
+ .job-item-meta .model-badge {
1684
+ font-size: 10px;
1685
+ padding: 0 5px;
1686
+ border-radius: 3px;
1687
+ border: 1px solid var(--border);
1688
+ color: var(--text-dim);
1689
+ }
1690
+
1691
+ .job-item-meta .priority-badge {
1692
+ font-size: 10px;
1693
+ padding: 0 5px;
1694
+ border-radius: 3px;
1695
+ }
1696
+
1697
+ .priority-badge.critical { border: 1px solid var(--red); color: var(--red); }
1698
+ .priority-badge.high { border: 1px solid var(--orange); color: var(--orange); }
1699
+
1700
+ .job-item-status {
1701
+ margin-top: 3px;
1702
+ margin-left: 16px;
1703
+ font-size: 11px;
1704
+ color: var(--text-dim);
1705
+ }
1706
+
1707
+ /* Jobs detail panel */
1708
+ .jobs-detail {
1709
+ flex: 1;
1710
+ display: flex;
1711
+ flex-direction: column;
1712
+ overflow: hidden;
1713
+ }
1714
+
1715
+ .jobs-detail-empty {
1716
+ flex: 1;
1717
+ display: flex;
1718
+ align-items: center;
1719
+ justify-content: center;
1720
+ color: var(--text-dim);
1721
+ font-size: 13px;
1722
+ }
1723
+
1724
+ .jobs-detail-content {
1725
+ flex: 1;
1726
+ overflow-y: auto;
1727
+ padding: 20px 24px;
1728
+ }
1729
+
1730
+ .job-detail-header {
1731
+ display: flex;
1732
+ align-items: flex-start;
1733
+ justify-content: space-between;
1734
+ margin-bottom: 16px;
1735
+ }
1736
+
1737
+ .job-detail-header h3 {
1738
+ font-size: 16px;
1739
+ font-weight: 600;
1740
+ color: var(--text-bright);
1741
+ margin-bottom: 4px;
1742
+ }
1743
+
1744
+ .job-detail-header .job-desc {
1745
+ font-size: 12px;
1746
+ color: var(--text-dim);
1747
+ margin-bottom: 8px;
1748
+ }
1749
+
1750
+ .job-detail-header .job-meta-line {
1751
+ font-size: 11px;
1752
+ color: var(--text-dim);
1753
+ display: flex;
1754
+ gap: 8px;
1755
+ align-items: center;
1756
+ flex-wrap: wrap;
1757
+ }
1758
+
1759
+ .job-detail-actions {
1760
+ display: flex;
1761
+ gap: 8px;
1762
+ align-items: center;
1763
+ flex-shrink: 0;
1764
+ }
1765
+
1766
+ .job-run-btn {
1767
+ padding: 6px 14px;
1768
+ border-radius: 6px;
1769
+ border: 1px solid var(--accent-dim);
1770
+ background: transparent;
1771
+ color: var(--accent);
1772
+ font-size: 12px;
1773
+ font-weight: 500;
1774
+ cursor: pointer;
1775
+ transition: all 0.15s;
1776
+ }
1777
+
1778
+ .job-run-btn:hover:not(:disabled) {
1779
+ background: var(--accent-dim);
1780
+ color: #000;
1781
+ }
1782
+
1783
+ .job-run-btn:disabled {
1784
+ opacity: 0.4;
1785
+ cursor: not-allowed;
1786
+ }
1787
+
1788
+ .job-run-btn.running {
1789
+ border-color: var(--blue);
1790
+ color: var(--blue);
1791
+ }
1792
+
1793
+ .job-toggle {
1794
+ position: relative;
1795
+ width: 36px;
1796
+ height: 20px;
1797
+ background: #333;
1798
+ border-radius: 10px;
1799
+ cursor: pointer;
1800
+ transition: background 0.2s;
1801
+ border: none;
1802
+ }
1803
+
1804
+ .job-toggle.enabled {
1805
+ background: var(--accent-dim);
1806
+ }
1807
+
1808
+ .job-toggle::after {
1809
+ content: '';
1810
+ position: absolute;
1811
+ top: 2px;
1812
+ left: 2px;
1813
+ width: 16px;
1814
+ height: 16px;
1815
+ border-radius: 50%;
1816
+ background: #fff;
1817
+ transition: transform 0.2s;
1818
+ }
1819
+
1820
+ .job-toggle.enabled::after {
1821
+ transform: translateX(16px);
1822
+ }
1823
+
1824
+ /* Job state card */
1825
+ .job-state-card {
1826
+ background: var(--bg-panel);
1827
+ border: 1px solid var(--border);
1828
+ border-radius: 8px;
1829
+ padding: 14px 16px;
1830
+ margin-bottom: 16px;
1831
+ font-size: 12px;
1832
+ }
1833
+
1834
+ .job-state-card .state-row {
1835
+ display: flex;
1836
+ justify-content: space-between;
1837
+ padding: 4px 0;
1838
+ color: var(--text-dim);
1839
+ }
1840
+
1841
+ .job-state-card .state-row .state-val {
1842
+ color: var(--text);
1843
+ font-weight: 500;
1844
+ }
1845
+
1846
+ .job-state-card .state-row .state-val.error {
1847
+ color: var(--red);
1848
+ }
1849
+
1850
+ .job-state-card .state-row .state-val.success {
1851
+ color: var(--accent);
1852
+ }
1853
+
1854
+ /* Job history table */
1855
+ .job-history-section h4 {
1856
+ font-size: 13px;
1857
+ font-weight: 600;
1858
+ color: var(--text-bright);
1859
+ margin-bottom: 10px;
1860
+ }
1861
+
1862
+ .job-history-table {
1863
+ width: 100%;
1864
+ border-collapse: collapse;
1865
+ font-size: 12px;
1866
+ }
1867
+
1868
+ .job-history-table th {
1869
+ text-align: left;
1870
+ padding: 6px 10px;
1871
+ border-bottom: 1px solid var(--border);
1872
+ color: var(--text-dim);
1873
+ font-weight: 500;
1874
+ font-size: 11px;
1875
+ }
1876
+
1877
+ .job-history-table td {
1878
+ padding: 6px 10px;
1879
+ border-bottom: 1px solid var(--border);
1880
+ color: var(--text);
1881
+ }
1882
+
1883
+ .result-badge {
1884
+ display: inline-block;
1885
+ padding: 1px 6px;
1886
+ border-radius: 3px;
1887
+ font-size: 10px;
1888
+ font-weight: 600;
1889
+ }
1890
+
1891
+ .result-badge.success { background: #0f2f0f; color: var(--accent); }
1892
+ .result-badge.failure, .result-badge.error { background: #2f0f0f; color: var(--red); }
1893
+ .result-badge.spawn-error { background: #2f1f0f; color: var(--orange); }
1894
+ .result-badge.timeout { background: #2f2f0f; color: #eab308; }
1895
+ .result-badge.pending { background: #0f1f2f; color: var(--blue); animation: pulse 1.5s infinite; }
1896
+ .result-badge.skipped { background: #1a1a1a; color: #666; }
1897
+
1898
+ /* Run sparkline */
1899
+ .job-sparkline {
1900
+ display: flex;
1901
+ gap: 1px;
1902
+ margin-bottom: 16px;
1903
+ }
1904
+
1905
+ .job-sparkline .spark {
1906
+ width: 6px;
1907
+ height: 16px;
1908
+ border-radius: 1px;
1909
+ }
1910
+
1911
+ .spark.s-success { background: var(--accent-dim); }
1912
+ .spark.s-failure, .spark.s-error { background: var(--red); }
1913
+ .spark.s-spawn-error { background: var(--orange); }
1914
+ .spark.s-timeout { background: #eab308; }
1915
+ .spark.s-pending { background: var(--blue); }
1916
+ .spark.s-skipped { background: #333; }
1917
+
1481
1918
  /* ── Mobile responsive ─────────────────────────────────── */
1482
1919
  @media (max-width: 768px) {
1483
1920
  .app {
@@ -1662,6 +2099,45 @@
1662
2099
  min-height: 44px;
1663
2100
  padding: 10px 16px;
1664
2101
  }
2102
+
2103
+ /* Vital signs mobile */
2104
+ .vital-signs {
2105
+ display: none;
2106
+ }
2107
+
2108
+ /* Jobs tab mobile */
2109
+ .jobs-container {
2110
+ flex-direction: column;
2111
+ }
2112
+
2113
+ .jobs-sidebar {
2114
+ width: 100%;
2115
+ min-width: 100%;
2116
+ border-right: none;
2117
+ border-bottom: 1px solid var(--border);
2118
+ max-height: none;
2119
+ }
2120
+
2121
+ .app.jobs-detail-active .jobs-sidebar {
2122
+ display: none;
2123
+ }
2124
+
2125
+ .app:not(.jobs-detail-active) .jobs-detail {
2126
+ display: none;
2127
+ }
2128
+
2129
+ .job-item {
2130
+ padding: 12px 14px;
2131
+ }
2132
+
2133
+ .job-detail-header {
2134
+ flex-direction: column;
2135
+ gap: 10px;
2136
+ }
2137
+
2138
+ .jobs-detail-content {
2139
+ padding: 14px;
2140
+ }
1665
2141
  }
1666
2142
  </style>
1667
2143
  </head>
@@ -1687,8 +2163,30 @@
1687
2163
  <button class="tab active" data-tab="sessions" onclick="switchTab('sessions')">Sessions <span class="tab-count" id="tabSessionCount">0</span></button>
1688
2164
  <button class="tab" data-tab="files" onclick="switchTab('files')">Files</button>
1689
2165
  <button class="tab" data-tab="dropzone" onclick="switchTab('dropzone')">Drop Zone</button>
2166
+ <button class="tab" data-tab="jobs" onclick="switchTab('jobs')">Jobs <span class="tab-count" id="tabJobCount">0</span></button>
1690
2167
  </nav>
1691
2168
  </div>
2169
+ <div class="vital-signs" id="vitalSigns">
2170
+ <div class="vital" id="vitalServer" onclick="switchTab('jobs')" title="Server status">
2171
+ <span class="vital-dot" id="vitalServerDot" style="background:var(--accent)"></span>
2172
+ <span id="vitalServerText">Healthy</span>
2173
+ </div>
2174
+ <span class="vital-sep"></span>
2175
+ <div class="vital" id="vitalSessions" onclick="switchTab('sessions')" title="Active sessions">
2176
+ <span class="vital-icon">&#x25CB;</span>
2177
+ <span id="vitalSessionsText">0/0</span>
2178
+ </div>
2179
+ <span class="vital-sep"></span>
2180
+ <div class="vital" id="vitalMemory" title="Memory pressure">
2181
+ <span id="vitalMemoryText">Mem 0%</span>
2182
+ <div class="vital-bar"><div class="vital-bar-fill" id="vitalMemoryBar" style="width:0%;background:var(--accent)"></div></div>
2183
+ </div>
2184
+ <span class="vital-sep"></span>
2185
+ <div class="vital" id="vitalJobs" onclick="switchTab('jobs')" title="Failing jobs" style="display:none">
2186
+ <span class="vital-icon" style="color:var(--red)">&#x26A0;</span>
2187
+ <span id="vitalJobsText" style="color:var(--red)">0 failing</span>
2188
+ </div>
2189
+ </div>
1692
2190
  <button class="wa-status-btn" id="waStatusBtn" onclick="toggleQrPanel()">WhatsApp</button>
1693
2191
  <div class="status-badge" id="connStatus">
1694
2192
  <span class="dot"></span>
@@ -1775,7 +2273,6 @@
1775
2273
  </div>
1776
2274
  </div>
1777
2275
  </div>
1778
- </div>
1779
2276
 
1780
2277
  <!-- Toast notifications -->
1781
2278
  <div class="toast-container" id="toastContainer"></div>
@@ -1855,6 +2352,39 @@
1855
2352
  </div>
1856
2353
  </div>
1857
2354
 
2355
+ <!-- Jobs tab -->
2356
+ <div class="jobs-container" id="jobsTab" style="display:none">
2357
+ <div class="jobs-sidebar">
2358
+ <div class="jobs-sidebar-header">
2359
+ <h2>Jobs</h2>
2360
+ <select class="jobs-sort" id="jobsSort" onchange="renderJobList()">
2361
+ <option value="status">Sort: Status</option>
2362
+ <option value="priority">Sort: Priority</option>
2363
+ <option value="name">Sort: Name</option>
2364
+ <option value="lastRun">Sort: Last Run</option>
2365
+ </select>
2366
+ </div>
2367
+ <div class="jobs-filter-bar" id="jobsFilterBar">
2368
+ <button class="jobs-filter-chip active" data-filter="all" onclick="setJobFilter('all')">All</button>
2369
+ <button class="jobs-filter-chip" data-filter="failing" onclick="setJobFilter('failing')">Failing</button>
2370
+ <button class="jobs-filter-chip" data-filter="disabled" onclick="setJobFilter('disabled')">Disabled</button>
2371
+ </div>
2372
+ <div class="jobs-list" id="jobsList">
2373
+ <div style="padding:20px;color:var(--text-dim);text-align:center">Loading jobs...</div>
2374
+ </div>
2375
+ </div>
2376
+ <div class="jobs-detail" id="jobsDetail">
2377
+ <div class="jobs-detail-empty" id="jobsDetailEmpty">
2378
+ <div style="text-align:center">
2379
+ <div style="font-size:24px;margin-bottom:8px">&#x2699;</div>
2380
+ <p>Select a job to view details</p>
2381
+ </div>
2382
+ </div>
2383
+ <div class="jobs-detail-content" id="jobsDetailContent" style="display:none"></div>
2384
+ </div>
2385
+ </div>
2386
+ </div>
2387
+
1858
2388
  <!-- WhatsApp QR panel (hidden by default) -->
1859
2389
  <div class="wa-qr-backdrop" id="waQrBackdrop" style="display:none" onclick="closeQrPanel()"></div>
1860
2390
  <div class="wa-qr-panel" id="waQrPanel" style="display:none">
@@ -1881,6 +2411,9 @@
1881
2411
  let activeSession = null;
1882
2412
  let term = null;
1883
2413
  let fitAddon = null;
2414
+ let historyLinesLoaded = 2000; // matches initial subscribe capture
2415
+ let historyLoading = false;
2416
+ let historyExhausted = false; // true when we've loaded all available history
1884
2417
 
1885
2418
  // ── Auth ─────────────────────────────────────────────────
1886
2419
  function authenticate() {
@@ -1920,6 +2453,7 @@
1920
2453
  document.getElementById('app').style.display = 'grid';
1921
2454
  connectWebSocket();
1922
2455
  startWaPolling();
2456
+ startVitalSigns();
1923
2457
  } else {
1924
2458
  showAuthError('Incorrect PIN');
1925
2459
  }
@@ -1944,6 +2478,7 @@
1944
2478
  document.getElementById('app').style.display = 'grid';
1945
2479
  connectWebSocket();
1946
2480
  startWaPolling();
2481
+ startVitalSigns();
1947
2482
  }
1948
2483
  })
1949
2484
  .catch(() => {});
@@ -2004,6 +2539,39 @@
2004
2539
  }
2005
2540
  break;
2006
2541
 
2542
+ case 'history':
2543
+ if (msg.session === activeSession && term) {
2544
+ const prevLineCount = historyLinesLoaded;
2545
+ historyLinesLoaded = msg.lines;
2546
+ // Check if we got more content than before
2547
+ const newLineCount = (msg.data || '').split('\n').length;
2548
+ const oldLineCount = prevLineCount;
2549
+ if (newLineCount <= oldLineCount + 10) {
2550
+ // No meaningful new content — we've hit the buffer limit
2551
+ historyExhausted = true;
2552
+ }
2553
+ // Calculate how many new lines were prepended so we can
2554
+ // restore the user's approximate scroll position after rewrite.
2555
+ const addedLines = Math.max(0, newLineCount - oldLineCount);
2556
+ const buf = term.buffer.active;
2557
+ const prevViewportY = buf.viewportY;
2558
+
2559
+ // Rewrite terminal with expanded history
2560
+ term.clear();
2561
+ term.write(msg.data);
2562
+
2563
+ // Restore scroll position: shift down by the number of new lines
2564
+ // so the user sees the same content they were looking at.
2565
+ requestAnimationFrame(() => {
2566
+ const newY = prevViewportY + addedLines;
2567
+ term.scrollToLine(Math.min(newY, term.buffer.active.baseY));
2568
+ });
2569
+
2570
+ historyLoading = false;
2571
+ hideHistorySpinner();
2572
+ }
2573
+ break;
2574
+
2007
2575
  case 'session_ended':
2008
2576
  if (msg.session === activeSession) {
2009
2577
  // Session ended — show in terminal
@@ -2149,6 +2717,11 @@
2149
2717
  }
2150
2718
 
2151
2719
  activeSession = tmuxSession;
2720
+ userIsFollowing = true; // Reset scroll tracking on session switch
2721
+ historyLinesLoaded = 2000; // Reset history state for new session
2722
+ historyLoading = false;
2723
+ historyExhausted = false;
2724
+ hideHistorySpinner();
2152
2725
 
2153
2726
  // Mobile: show terminal, hide sidebar
2154
2727
  document.getElementById('app').classList.add('terminal-active');
@@ -2225,7 +2798,7 @@
2225
2798
  fontFamily: "'SF Mono', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace",
2226
2799
  cursorBlink: false,
2227
2800
  cursorStyle: 'underline',
2228
- scrollback: 10000,
2801
+ scrollback: 50000,
2229
2802
  convertEol: true,
2230
2803
  });
2231
2804
 
@@ -2243,17 +2816,154 @@
2243
2816
  requestAnimationFrame(() => {
2244
2817
  try { fitAddon.fit(); } catch {}
2245
2818
  });
2819
+
2820
+ // Track user scroll position for auto-follow behavior.
2821
+ // When user scrolls up, stop auto-following. When they scroll
2822
+ // back near the bottom, resume following.
2823
+ // Also trigger history loading when user scrolls near the top.
2824
+ term.onScroll(() => {
2825
+ const buf = term.buffer.active;
2826
+ const atBottom = buf.baseY === 0 || (buf.baseY - buf.viewportY) <= 3;
2827
+
2828
+ if (atBottom && !userIsFollowing) {
2829
+ // User scrolled back to bottom — resume following and apply pending output
2830
+ userIsFollowing = true;
2831
+ if (pendingOutputData) {
2832
+ const data = pendingOutputData;
2833
+ pendingOutputData = null;
2834
+ term.clear();
2835
+ term.write(data);
2836
+ term.scrollToBottom();
2837
+ }
2838
+ } else if (!atBottom) {
2839
+ userIsFollowing = false;
2840
+ }
2841
+
2842
+ // Infinite scroll: load more history when scrolled near the top
2843
+ if (buf.viewportY <= 10 && !historyLoading && !historyExhausted && activeSession) {
2844
+ loadMoreHistory();
2845
+ }
2846
+ });
2847
+
2848
+ // Also listen on the xterm viewport DOM element for wheel events.
2849
+ // term.onScroll doesn't fire when the user is already at the scroll
2850
+ // boundary — the wheel event lets us detect "trying to scroll past the top."
2851
+ const xtermViewport = container.querySelector('.xterm-viewport');
2852
+ if (xtermViewport) {
2853
+ xtermViewport.addEventListener('wheel', (e) => {
2854
+ // Scrolling up while near top → load more history
2855
+ if (e.deltaY < 0) {
2856
+ const buf = term.buffer.active;
2857
+ if (buf.viewportY <= 10 && !historyLoading && !historyExhausted && activeSession) {
2858
+ loadMoreHistory();
2859
+ }
2860
+ }
2861
+ // Scrolling down — check if we hit bottom to resume
2862
+ if (e.deltaY > 0 && !userIsFollowing) {
2863
+ const buf = term.buffer.active;
2864
+ const atBottom = buf.baseY === 0 || (buf.baseY - buf.viewportY) <= 5;
2865
+ if (atBottom) {
2866
+ userIsFollowing = true;
2867
+ if (pendingOutputData) {
2868
+ const data = pendingOutputData;
2869
+ pendingOutputData = null;
2870
+ term.clear();
2871
+ term.write(data);
2872
+ term.scrollToBottom();
2873
+ }
2874
+ }
2875
+ }
2876
+ }, { passive: true });
2877
+ }
2246
2878
  }
2247
2879
 
2880
+ /** Request more terminal history from the server */
2881
+ function loadMoreHistory() {
2882
+ if (historyLoading || historyExhausted || !activeSession) return;
2883
+ const nextBatch = Math.min(historyLinesLoaded + 5000, 50000);
2884
+ if (nextBatch <= historyLinesLoaded) {
2885
+ historyExhausted = true;
2886
+ return;
2887
+ }
2888
+ historyLoading = true;
2889
+ showHistorySpinner();
2890
+ wsSend({ type: 'history', session: activeSession, lines: nextBatch });
2891
+ }
2892
+
2893
+ function showHistorySpinner() {
2894
+ let spinner = document.getElementById('historySpinner');
2895
+ if (!spinner) {
2896
+ spinner = document.createElement('div');
2897
+ spinner.id = 'historySpinner';
2898
+ spinner.style.cssText = 'position:absolute;top:8px;left:50%;transform:translateX(-50%);z-index:10;background:var(--bg-secondary);color:var(--text-muted);padding:4px 12px;border-radius:4px;font-size:11px;opacity:0.9;';
2899
+ spinner.textContent = 'Loading history…';
2900
+ const container = document.getElementById('terminalContainer');
2901
+ if (container) container.style.position = 'relative';
2902
+ container?.appendChild(spinner);
2903
+ }
2904
+ spinner.style.display = 'block';
2905
+ }
2906
+
2907
+ function hideHistorySpinner() {
2908
+ const spinner = document.getElementById('historySpinner');
2909
+ if (spinner) spinner.style.display = 'none';
2910
+ }
2911
+
2912
+ /** Track whether user is near the bottom of terminal output */
2913
+ let userIsFollowing = true;
2914
+ /** Pending output data received while user is scrolled up — applied when they return to bottom */
2915
+ let pendingOutputData = null;
2916
+
2248
2917
  function renderTerminalOutput(data) {
2249
2918
  if (!term) return;
2250
- // Replace full terminal content with latest capture
2919
+
2920
+ // If user is scrolled up reading history, DON'T rewrite the terminal.
2921
+ // Cache the latest data and apply it when they scroll back to the bottom.
2922
+ // This prevents the "jumping around" problem caused by clear()+write() every 500ms.
2923
+ if (!userIsFollowing) {
2924
+ pendingOutputData = data;
2925
+ showResumeButton();
2926
+ return;
2927
+ }
2928
+
2929
+ // User is following — apply the update
2930
+ hideResumeButton();
2251
2931
  term.clear();
2252
2932
  term.write(data);
2253
- // Auto-scroll to bottom
2254
2933
  term.scrollToBottom();
2255
2934
  }
2256
2935
 
2936
+ /** Show a "Resume live output" button when updates are paused */
2937
+ function showResumeButton() {
2938
+ let btn = document.getElementById('resumeBtn');
2939
+ if (!btn) {
2940
+ btn = document.createElement('button');
2941
+ btn.id = 'resumeBtn';
2942
+ btn.textContent = '▼ Resume live output';
2943
+ btn.style.cssText = 'position:absolute;bottom:12px;left:50%;transform:translateX(-50%);z-index:10;background:var(--accent);color:#000;border:none;padding:6px 16px;border-radius:4px;font-size:12px;cursor:pointer;font-weight:600;opacity:0.95;box-shadow:0 2px 8px rgba(0,0,0,0.3);';
2944
+ btn.onclick = () => {
2945
+ userIsFollowing = true;
2946
+ if (pendingOutputData) {
2947
+ const data = pendingOutputData;
2948
+ pendingOutputData = null;
2949
+ term.clear();
2950
+ term.write(data);
2951
+ term.scrollToBottom();
2952
+ }
2953
+ hideResumeButton();
2954
+ };
2955
+ const container = document.getElementById('terminalContainer');
2956
+ if (container) container.style.position = 'relative';
2957
+ container?.appendChild(btn);
2958
+ }
2959
+ btn.style.display = 'block';
2960
+ }
2961
+
2962
+ function hideResumeButton() {
2963
+ const btn = document.getElementById('resumeBtn');
2964
+ if (btn) btn.style.display = 'none';
2965
+ }
2966
+
2257
2967
  function goBack() {
2258
2968
  // Mobile: go back to session list
2259
2969
  if (activeSession) {
@@ -2547,11 +3257,51 @@
2547
3257
  }
2548
3258
  });
2549
3259
 
2550
- // ── Tab System ─────────────────────────────────────────────
3260
+ // ── Tab System (Data-Driven Registry) ──────────────────────
2551
3261
  let currentTab = 'sessions';
2552
3262
 
3263
+ const TAB_REGISTRY = [
3264
+ {
3265
+ id: 'sessions',
3266
+ panels: ['sessionsTab', 'mainPanel'],
3267
+ display: ['', ''],
3268
+ onActivate: null,
3269
+ },
3270
+ {
3271
+ id: 'files',
3272
+ panels: ['filesTab'],
3273
+ display: ['flex'],
3274
+ onActivate: () => { if (!fileTreeLoaded) loadFileTree(); },
3275
+ },
3276
+ {
3277
+ id: 'dropzone',
3278
+ panels: ['dropzoneTab'],
3279
+ display: ['flex'],
3280
+ onActivate: () => {
3281
+ setTimeout(() => document.getElementById('dzContent')?.focus(), 100);
3282
+ loadDzSessions();
3283
+ loadDzHistory();
3284
+ },
3285
+ },
3286
+ {
3287
+ id: 'jobs',
3288
+ panels: ['jobsTab'],
3289
+ display: ['flex'],
3290
+ onActivate: () => {
3291
+ if (!jobsLoaded) loadJobs();
3292
+ connectJobsSSE();
3293
+ },
3294
+ onDeactivate: () => { disconnectJobsSSE(); },
3295
+ },
3296
+ ];
3297
+
2553
3298
  function switchTab(tabName) {
2554
3299
  if (tabName === currentTab) return;
3300
+
3301
+ // Deactivate current tab
3302
+ const prevTab = TAB_REGISTRY.find(t => t.id === currentTab);
3303
+ if (prevTab?.onDeactivate) prevTab.onDeactivate();
3304
+
2555
3305
  currentTab = tabName;
2556
3306
 
2557
3307
  // Update tab buttons
@@ -2559,34 +3309,24 @@
2559
3309
  btn.classList.toggle('active', btn.dataset.tab === tabName);
2560
3310
  });
2561
3311
 
2562
- // Toggle tab content visibility
2563
- const sessionsTab = document.getElementById('sessionsTab');
2564
- const mainPanel = document.getElementById('mainPanel');
2565
- const filesTab = document.getElementById('filesTab');
2566
- const dropzoneTab = document.getElementById('dropzoneTab');
2567
-
2568
- sessionsTab.style.display = 'none';
2569
- mainPanel.style.display = 'none';
2570
- filesTab.style.display = 'none';
2571
- dropzoneTab.style.display = 'none';
2572
-
2573
- if (tabName === 'sessions') {
2574
- sessionsTab.style.display = '';
2575
- mainPanel.style.display = '';
2576
- } else if (tabName === 'files') {
2577
- filesTab.style.display = 'flex';
2578
- // Load file tree on first switch
2579
- if (!fileTreeLoaded) loadFileTree();
2580
- } else if (tabName === 'dropzone') {
2581
- dropzoneTab.style.display = 'flex';
2582
- // Auto-focus textarea
2583
- setTimeout(() => document.getElementById('dzContent')?.focus(), 100);
2584
- // Load session list and paste history
2585
- loadDzSessions();
2586
- loadDzHistory();
3312
+ // Hide all panels
3313
+ for (const tab of TAB_REGISTRY) {
3314
+ for (const panelId of tab.panels) {
3315
+ const el = document.getElementById(panelId);
3316
+ if (el) el.style.display = 'none';
3317
+ }
3318
+ }
3319
+
3320
+ // Show active tab panels
3321
+ const activeTab = TAB_REGISTRY.find(t => t.id === tabName);
3322
+ if (activeTab) {
3323
+ activeTab.panels.forEach((panelId, i) => {
3324
+ const el = document.getElementById(panelId);
3325
+ if (el) el.style.display = activeTab.display[i] || '';
3326
+ });
3327
+ if (activeTab.onActivate) activeTab.onActivate();
2587
3328
  }
2588
3329
 
2589
- // Update URL
2590
3330
  updateFileUrl();
2591
3331
  }
2592
3332
 
@@ -3470,6 +4210,528 @@
3470
4210
  return d.innerHTML;
3471
4211
  }
3472
4212
 
4213
+ // ── Vital Signs Strip ──────────────────────────────────────
4214
+ let vitalSignsInterval = null;
4215
+
4216
+ function startVitalSigns() {
4217
+ pollVitalSigns();
4218
+ vitalSignsInterval = setInterval(pollVitalSigns, 30000);
4219
+ }
4220
+
4221
+ async function pollVitalSigns() {
4222
+ try {
4223
+ const resp = await fetch('/health', { headers: { 'Authorization': 'Bearer ' + token } });
4224
+ if (!resp.ok) return;
4225
+ const h = await resp.json();
4226
+
4227
+ // Server status
4228
+ const serverDot = document.getElementById('vitalServerDot');
4229
+ const serverText = document.getElementById('vitalServerText');
4230
+ const serverVital = document.getElementById('vitalServer');
4231
+ if (h.status === 'ok') {
4232
+ serverDot.style.background = 'var(--accent)';
4233
+ serverText.textContent = h.uptimeHuman || 'Healthy';
4234
+ serverVital.className = 'vital';
4235
+ } else {
4236
+ serverDot.style.background = 'var(--orange)';
4237
+ serverText.textContent = 'Degraded';
4238
+ serverVital.className = 'vital warn';
4239
+ }
4240
+
4241
+ // Sessions
4242
+ const sessEl = document.getElementById('vitalSessionsText');
4243
+ const sessVital = document.getElementById('vitalSessions');
4244
+ const cur = h.sessions?.current ?? 0;
4245
+ const max = h.sessions?.max ?? 0;
4246
+ sessEl.textContent = cur + '/' + max;
4247
+ if (cur >= max) {
4248
+ sessVital.className = 'vital warn';
4249
+ } else {
4250
+ sessVital.className = 'vital';
4251
+ }
4252
+
4253
+ // Memory
4254
+ const memPct = h.memoryPressure?.pressurePercent ?? h.systemMemory?.usedPercent ?? 0;
4255
+ const memText = document.getElementById('vitalMemoryText');
4256
+ const memBar = document.getElementById('vitalMemoryBar');
4257
+ const memVital = document.getElementById('vitalMemory');
4258
+ memText.textContent = 'Mem ' + Math.round(memPct) + '%';
4259
+ memBar.style.width = Math.min(memPct, 100) + '%';
4260
+ if (memPct >= 75) {
4261
+ memBar.style.background = 'var(--red)';
4262
+ memVital.className = 'vital crit';
4263
+ } else if (memPct >= 60) {
4264
+ memBar.style.background = 'var(--orange)';
4265
+ memVital.className = 'vital warn';
4266
+ } else {
4267
+ memBar.style.background = 'var(--accent)';
4268
+ memVital.className = 'vital';
4269
+ }
4270
+
4271
+ // Failing jobs
4272
+ const failing = h.jobs?.failing || [];
4273
+ const jobsVital = document.getElementById('vitalJobs');
4274
+ const jobsText = document.getElementById('vitalJobsText');
4275
+ if (failing.length > 0) {
4276
+ jobsVital.style.display = 'flex';
4277
+ jobsText.textContent = failing.length + ' failing';
4278
+ } else {
4279
+ jobsVital.style.display = 'none';
4280
+ }
4281
+
4282
+ // Update tab badge
4283
+ const jobCount = document.getElementById('tabJobCount');
4284
+ if (jobCount) {
4285
+ const total = h.jobs?.total ?? 0;
4286
+ if (failing.length > 0) {
4287
+ jobCount.textContent = failing.length;
4288
+ jobCount.style.background = 'var(--red)';
4289
+ jobCount.style.color = '#fff';
4290
+ } else {
4291
+ jobCount.textContent = total;
4292
+ jobCount.style.background = '';
4293
+ jobCount.style.color = '';
4294
+ }
4295
+ }
4296
+ } catch (e) {
4297
+ // Vital signs polling failure is silent
4298
+ }
4299
+ }
4300
+
4301
+ // ── Jobs Tab ─────────────────────────────────────────────────
4302
+ let jobsLoaded = false;
4303
+ let jobsData = [];
4304
+ let selectedJob = null;
4305
+ let jobFilter = 'all';
4306
+ let jobsSSE = null;
4307
+
4308
+ const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
4309
+
4310
+ function cronToHuman(cron) {
4311
+ try {
4312
+ if (!cron || typeof cron !== 'string') return cron || '—';
4313
+ const parts = cron.trim().split(/\s+/);
4314
+ if (parts.length < 5) return cron;
4315
+ const [min, hour, dom, mon, dow] = parts;
4316
+
4317
+ // */N * * * * → Every N minutes
4318
+ if (/^\*\/(\d+)$/.test(min) && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
4319
+ const n = parseInt(min.slice(2));
4320
+ return n === 1 ? 'Every minute' : 'Every ' + n + ' min';
4321
+ }
4322
+ // 0 */N * * * → Every N hours
4323
+ if (min === '0' && /^\*\/(\d+)$/.test(hour) && dom === '*' && mon === '*' && dow === '*') {
4324
+ const n = parseInt(hour.slice(2));
4325
+ return n === 1 ? 'Every hour' : 'Every ' + n + 'h';
4326
+ }
4327
+ // 0 N * * * → Daily at N:00
4328
+ if (/^\d+$/.test(min) && /^\d+$/.test(hour) && dom === '*' && mon === '*' && dow === '*') {
4329
+ const h = parseInt(hour);
4330
+ const m = parseInt(min);
4331
+ const time = (h % 12 || 12) + (m ? ':' + String(m).padStart(2, '0') : '') + (h >= 12 ? ' PM' : ' AM');
4332
+ return 'Daily at ' + time;
4333
+ }
4334
+ // 0 N * * D → Day at N:00
4335
+ if (/^\d+$/.test(min) && /^\d+$/.test(hour) && dom === '*' && mon === '*' && /^\d+$/.test(dow)) {
4336
+ const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
4337
+ const h = parseInt(hour);
4338
+ const time = (h % 12 || 12) + (h >= 12 ? ' PM' : ' AM');
4339
+ return (days[parseInt(dow)] || dow) + ' at ' + time;
4340
+ }
4341
+ // * * * * * → Every minute
4342
+ if (min === '*' && hour === '*') return 'Every minute';
4343
+ // 0 * * * * → Every hour
4344
+ if (min === '0' && hour === '*') return 'Every hour';
4345
+ return cron;
4346
+ } catch {
4347
+ return cron;
4348
+ }
4349
+ }
4350
+
4351
+ async function loadJobs() {
4352
+ jobsLoaded = true;
4353
+ try {
4354
+ const data = await apiFetch('/jobs');
4355
+ const jobs = data.jobs || data;
4356
+ jobsData = Array.isArray(jobs) ? jobs : [];
4357
+ renderJobList();
4358
+ } catch (e) {
4359
+ document.getElementById('jobsList').innerHTML =
4360
+ '<div style="padding:20px;color:var(--red);text-align:center">Failed to load jobs</div>';
4361
+ }
4362
+ }
4363
+
4364
+ function setJobFilter(filter) {
4365
+ jobFilter = filter;
4366
+ document.querySelectorAll('.jobs-filter-chip').forEach(c => {
4367
+ c.classList.toggle('active', c.dataset.filter === filter);
4368
+ });
4369
+ renderJobList();
4370
+ }
4371
+
4372
+ function renderJobList() {
4373
+ const list = document.getElementById('jobsList');
4374
+ const sort = document.getElementById('jobsSort').value;
4375
+
4376
+ let filtered = jobsData.filter(j => {
4377
+ if (jobFilter === 'failing') {
4378
+ const s = j.state || {};
4379
+ return (s.consecutiveFailures || 0) > 0;
4380
+ }
4381
+ if (jobFilter === 'disabled') return !j.enabled;
4382
+ return true;
4383
+ });
4384
+
4385
+ // Sort
4386
+ filtered.sort((a, b) => {
4387
+ const sa = a.state || {};
4388
+ const sb = b.state || {};
4389
+ if (sort === 'status') {
4390
+ const fa = sa.consecutiveFailures || 0;
4391
+ const fb = sb.consecutiveFailures || 0;
4392
+ if (fb !== fa) return fb - fa; // Failing first
4393
+ return (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2);
4394
+ }
4395
+ if (sort === 'priority') {
4396
+ return (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2);
4397
+ }
4398
+ if (sort === 'name') return (a.slug || '').localeCompare(b.slug || '');
4399
+ if (sort === 'lastRun') {
4400
+ const ta = sa.lastRun ? new Date(sa.lastRun).getTime() : 0;
4401
+ const tb = sb.lastRun ? new Date(sb.lastRun).getTime() : 0;
4402
+ return tb - ta;
4403
+ }
4404
+ return 0;
4405
+ });
4406
+
4407
+ if (filtered.length === 0) {
4408
+ list.innerHTML = '<div style="padding:20px;color:var(--text-dim);text-align:center">No jobs match filter</div>';
4409
+ return;
4410
+ }
4411
+
4412
+ list.innerHTML = filtered.map(j => {
4413
+ const s = j.state || {};
4414
+ const failures = s.consecutiveFailures || 0;
4415
+ const isRunning = s.lastResult === 'pending';
4416
+ let dotClass = 'healthy';
4417
+ if (!j.enabled) dotClass = 'disabled';
4418
+ else if (isRunning) dotClass = 'running';
4419
+ else if (failures >= 3) dotClass = 'failing';
4420
+ else if (failures > 0) dotClass = 'warn';
4421
+
4422
+ const schedule = cronToHuman(j.schedule);
4423
+ const lastResult = s.lastResult || '—';
4424
+ const lastTime = s.lastRun ? timeAgo(new Date(s.lastRun)) : 'never';
4425
+ const isActive = selectedJob === j.slug ? ' active' : '';
4426
+
4427
+ const priorityBadge = (j.priority === 'critical' || j.priority === 'high')
4428
+ ? '<span class="priority-badge ' + esc(j.priority) + '">' + esc(j.priority) + '</span>' : '';
4429
+
4430
+ return '<div class="job-item' + isActive + '" onclick="selectJob(\'' + esc(j.slug) + '\')">'
4431
+ + '<div class="job-item-top">'
4432
+ + '<span class="job-status-dot ' + dotClass + '"></span>'
4433
+ + '<span class="job-item-name">' + esc(j.slug) + '</span>'
4434
+ + (failures > 0 ? '<span class="job-failure-count">' + failures + ' fail</span>' : '')
4435
+ + '</div>'
4436
+ + '<div class="job-item-meta">'
4437
+ + '<span>' + esc(schedule) + '</span>'
4438
+ + '<span class="model-badge">' + esc(j.model || '—') + '</span>'
4439
+ + priorityBadge
4440
+ + '</div>'
4441
+ + '<div class="job-item-status">'
4442
+ + '<span>' + esc(lastResult) + '</span> &middot; <span>' + esc(lastTime) + '</span>'
4443
+ + '</div>'
4444
+ + '</div>';
4445
+ }).join('');
4446
+ }
4447
+
4448
+ async function selectJob(slug) {
4449
+ selectedJob = slug;
4450
+ renderJobList(); // Update active highlight
4451
+
4452
+ const job = jobsData.find(j => j.slug === slug);
4453
+ if (!job) return;
4454
+
4455
+ const detail = document.getElementById('jobsDetailContent');
4456
+ const empty = document.getElementById('jobsDetailEmpty');
4457
+ empty.style.display = 'none';
4458
+ detail.style.display = '';
4459
+
4460
+ // Mobile: show detail
4461
+ document.getElementById('app').classList.add('jobs-detail-active');
4462
+
4463
+ const s = job.state || {};
4464
+ const failures = s.consecutiveFailures || 0;
4465
+ const schedule = cronToHuman(job.schedule);
4466
+ const lastResult = s.lastResult || '—';
4467
+ const lastTime = s.lastRun ? timeAgo(new Date(s.lastRun)) : 'never';
4468
+ const nextTime = s.nextScheduled ? timeAgo(new Date(s.nextScheduled)).replace(' ago', '') : '—';
4469
+ const isRunning = s.lastResult === 'pending';
4470
+
4471
+ let statusText = 'Healthy';
4472
+ let statusClass = 'success';
4473
+ if (!job.enabled) { statusText = 'Disabled'; statusClass = ''; }
4474
+ else if (failures >= 3) { statusText = 'Failing (' + failures + ' consecutive)'; statusClass = 'error'; }
4475
+ else if (failures > 0) { statusText = 'Warning (' + failures + ' failures)'; statusClass = 'error'; }
4476
+ else if (isRunning) { statusText = 'Running'; statusClass = ''; }
4477
+
4478
+ const tags = (job.tags || []).map(t => esc(t)).join(', ') || '—';
4479
+
4480
+ detail.innerHTML = '<div class="job-detail-header">'
4481
+ + '<div>'
4482
+ + '<button class="back-btn" onclick="jobsGoBack()" style="display:none;margin-right:8px">&larr;</button>'
4483
+ + '<h3>' + esc(job.slug) + '</h3>'
4484
+ + '<div class="job-desc">' + esc(job.description || job.name || '') + '</div>'
4485
+ + '<div class="job-meta-line">'
4486
+ + '<span>' + esc(job.schedule) + ' (' + esc(schedule) + ')</span>'
4487
+ + '<span class="model-badge">' + esc(job.model || '—') + '</span>'
4488
+ + (job.priority ? '<span class="priority-badge ' + esc(job.priority) + '">' + esc(job.priority) + '</span>' : '')
4489
+ + '<span>Tags: ' + tags + '</span>'
4490
+ + '</div></div>'
4491
+ + '<div class="job-detail-actions">'
4492
+ + '<button class="job-run-btn" id="jobRunBtn" onclick="runJob(\'' + esc(job.slug) + '\')"'
4493
+ + (isRunning ? ' disabled' : '') + '>'
4494
+ + (isRunning ? 'Running...' : 'Run Now') + '</button>'
4495
+ + '<button class="job-toggle' + (job.enabled ? ' enabled' : '') + '" id="jobToggle" '
4496
+ + 'onclick="toggleJob(\'' + esc(job.slug) + '\', ' + (!job.enabled) + ')" '
4497
+ + 'title="' + (job.enabled ? 'Disable' : 'Enable') + '"></button>'
4498
+ + '</div></div>'
4499
+ + '<div class="job-state-card">'
4500
+ + '<div class="state-row"><span>Status</span><span class="state-val ' + statusClass + '">' + esc(statusText) + '</span></div>'
4501
+ + '<div class="state-row"><span>Last Run</span><span class="state-val">' + esc(lastTime) + '</span></div>'
4502
+ + '<div class="state-row"><span>Last Result</span><span class="state-val ' + (lastResult === 'success' ? 'success' : failures > 0 ? 'error' : '') + '">' + esc(lastResult) + '</span></div>'
4503
+ + (s.lastError ? '<div class="state-row"><span>Error</span><span class="state-val error">' + esc(s.lastError) + '</span></div>' : '')
4504
+ + '<div class="state-row"><span>Next Run</span><span class="state-val">' + esc(nextTime) + '</span></div>'
4505
+ + '</div>'
4506
+ + '<div id="jobSparkline" class="job-sparkline"></div>'
4507
+ + '<div class="job-history-section"><h4>Run History</h4>'
4508
+ + '<div id="jobHistoryTable">Loading...</div></div>';
4509
+
4510
+ // Show back button on mobile
4511
+ if (window.innerWidth <= 768) {
4512
+ detail.querySelector('.back-btn').style.display = 'inline-block';
4513
+ }
4514
+
4515
+ // Load history
4516
+ loadJobHistory(slug);
4517
+ }
4518
+
4519
+ function jobsGoBack() {
4520
+ document.getElementById('app').classList.remove('jobs-detail-active');
4521
+ selectedJob = null;
4522
+ document.getElementById('jobsDetailContent').style.display = 'none';
4523
+ document.getElementById('jobsDetailEmpty').style.display = '';
4524
+ renderJobList();
4525
+ }
4526
+
4527
+ async function loadJobHistory(slug) {
4528
+ try {
4529
+ const data = await apiFetch('/jobs/' + encodeURIComponent(slug) + '/history?limit=50');
4530
+ const runs = data.runs || [];
4531
+ const table = document.getElementById('jobHistoryTable');
4532
+ const sparkline = document.getElementById('jobSparkline');
4533
+
4534
+ // Sparkline
4535
+ if (runs.length > 0) {
4536
+ sparkline.innerHTML = runs.slice().reverse().slice(-50).map(r => {
4537
+ const cls = 's-' + (r.result || 'pending');
4538
+ const title = (r.result || '?') + ' — ' + new Date(r.startedAt || r.completedAt).toLocaleTimeString();
4539
+ return '<div class="spark ' + cls + '" title="' + esc(title) + '"></div>';
4540
+ }).join('');
4541
+ }
4542
+
4543
+ if (runs.length === 0) {
4544
+ table.innerHTML = '<div style="color:var(--text-dim)">No run history</div>';
4545
+ return;
4546
+ }
4547
+
4548
+ table.innerHTML = '<table class="job-history-table">'
4549
+ + '<thead><tr><th>Time</th><th>Result</th><th>Duration</th><th>Error</th></tr></thead>'
4550
+ + '<tbody>' + runs.slice(0, 100).map(r => {
4551
+ const time = r.startedAt ? new Date(r.startedAt).toLocaleTimeString() : '—';
4552
+ const result = r.result || '—';
4553
+ const dur = r.durationSeconds != null ? r.durationSeconds + 's'
4554
+ : r.durationMs != null ? Math.round(r.durationMs / 1000) + 's' : '—';
4555
+ const error = r.error ? esc(r.error).substring(0, 80) : '—';
4556
+ return '<tr>'
4557
+ + '<td>' + esc(time) + '</td>'
4558
+ + '<td><span class="result-badge ' + esc(result) + '">' + esc(result) + '</span></td>'
4559
+ + '<td>' + esc(dur) + '</td>'
4560
+ + '<td style="color:var(--text-dim);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + error + '</td>'
4561
+ + '</tr>';
4562
+ }).join('') + '</tbody></table>';
4563
+ } catch (e) {
4564
+ document.getElementById('jobHistoryTable').innerHTML =
4565
+ '<div style="color:var(--red)">Failed to load history</div>';
4566
+ }
4567
+ }
4568
+
4569
+ // ── Run Now / Enable-Disable ─────────────────────────────────
4570
+
4571
+ async function runJob(slug) {
4572
+ const btn = document.getElementById('jobRunBtn');
4573
+ if (!btn) return;
4574
+ btn.disabled = true;
4575
+ btn.classList.add('running');
4576
+ btn.textContent = 'Running...';
4577
+ const startTime = Date.now();
4578
+
4579
+ // Update elapsed timer
4580
+ const timer = setInterval(() => {
4581
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
4582
+ btn.textContent = 'Running... ' + elapsed + 's';
4583
+ }, 1000);
4584
+
4585
+ try {
4586
+ const resp = await fetch('/jobs/' + encodeURIComponent(slug) + '/run', {
4587
+ method: 'POST',
4588
+ headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
4589
+ });
4590
+ const data = await resp.json();
4591
+
4592
+ if (resp.status === 409) {
4593
+ clearInterval(timer);
4594
+ btn.textContent = 'Already running';
4595
+ setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; btn.classList.remove('running'); }, 3000);
4596
+ return;
4597
+ }
4598
+ if (resp.status === 429) {
4599
+ clearInterval(timer);
4600
+ btn.textContent = 'Rate limited';
4601
+ setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; btn.classList.remove('running'); }, 3000);
4602
+ return;
4603
+ }
4604
+ if (!resp.ok) {
4605
+ clearInterval(timer);
4606
+ btn.textContent = 'Error';
4607
+ setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; btn.classList.remove('running'); }, 3000);
4608
+ return;
4609
+ }
4610
+
4611
+ // Poll for completion (SSE handles live updates, but poll as fallback)
4612
+ const runId = data.runId;
4613
+ let attempts = 0;
4614
+ const pollCompletion = setInterval(async () => {
4615
+ attempts++;
4616
+ if (attempts > 60) { // 120s timeout
4617
+ clearInterval(pollCompletion);
4618
+ clearInterval(timer);
4619
+ btn.textContent = 'Still running...';
4620
+ setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; btn.classList.remove('running'); }, 3000);
4621
+ return;
4622
+ }
4623
+ try {
4624
+ await loadJobs(); // Refresh job states
4625
+ const job = jobsData.find(j => j.slug === slug);
4626
+ if (job && job.state && job.state.lastResult !== 'pending') {
4627
+ clearInterval(pollCompletion);
4628
+ clearInterval(timer);
4629
+ btn.classList.remove('running');
4630
+ btn.textContent = job.state.lastResult === 'success' ? 'Done!' : job.state.lastResult;
4631
+ if (selectedJob === slug) loadJobHistory(slug);
4632
+ setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; }, 3000);
4633
+ }
4634
+ } catch {}
4635
+ }, 2000);
4636
+ } catch (e) {
4637
+ clearInterval(timer);
4638
+ btn.textContent = 'Error';
4639
+ setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; btn.classList.remove('running'); }, 3000);
4640
+ }
4641
+ }
4642
+
4643
+ async function toggleJob(slug, enabled) {
4644
+ // Confirmation for critical/high priority jobs
4645
+ const job = jobsData.find(j => j.slug === slug);
4646
+ if (job && !enabled && (job.priority === 'critical' || job.priority === 'high')) {
4647
+ if (!confirm('Disable ' + slug + '? This is a ' + job.priority + '-priority job.')) return;
4648
+ }
4649
+
4650
+ try {
4651
+ const resp = await fetch('/jobs/' + encodeURIComponent(slug), {
4652
+ method: 'PATCH',
4653
+ headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
4654
+ body: JSON.stringify({ enabled }),
4655
+ });
4656
+ if (resp.ok) {
4657
+ await loadJobs();
4658
+ if (selectedJob === slug) selectJob(slug);
4659
+ }
4660
+ } catch {}
4661
+ }
4662
+
4663
+ // ── Jobs SSE ─────────────────────────────────────────────────
4664
+
4665
+ function connectJobsSSE() {
4666
+ if (jobsSSE) return; // Already connected
4667
+ try {
4668
+ // EventSource doesn't support custom headers, so pass token as query param
4669
+ jobsSSE = new EventSource('/jobs/events?token=' + encodeURIComponent(token));
4670
+
4671
+ jobsSSE.addEventListener('snapshot', (e) => {
4672
+ try {
4673
+ const data = JSON.parse(e.data);
4674
+ if (data.jobs) {
4675
+ // Merge SSE snapshot into existing jobs data
4676
+ for (const sj of data.jobs) {
4677
+ const existing = jobsData.find(j => j.slug === sj.slug);
4678
+ if (existing && sj.state) {
4679
+ existing.state = sj.state;
4680
+ if (sj.enabled !== undefined) existing.enabled = sj.enabled;
4681
+ }
4682
+ }
4683
+ renderJobList();
4684
+ if (selectedJob) {
4685
+ const job = jobsData.find(j => j.slug === selectedJob);
4686
+ if (job) selectJob(selectedJob);
4687
+ }
4688
+ }
4689
+ } catch {}
4690
+ });
4691
+
4692
+ jobsSSE.addEventListener('job-state', (e) => {
4693
+ try {
4694
+ const data = JSON.parse(e.data);
4695
+ const job = jobsData.find(j => j.slug === data.slug);
4696
+ if (job && data.state) {
4697
+ job.state = data.state;
4698
+ renderJobList();
4699
+ if (selectedJob === data.slug) selectJob(data.slug);
4700
+ }
4701
+ } catch {}
4702
+ });
4703
+
4704
+ jobsSSE.onerror = () => {
4705
+ // SSE will auto-reconnect, but clean up if tab switches away
4706
+ if (currentTab !== 'jobs') {
4707
+ disconnectJobsSSE();
4708
+ }
4709
+ };
4710
+ } catch {
4711
+ // SSE not supported or connection failed — fall back to polling
4712
+ jobsSSE = null;
4713
+ }
4714
+ }
4715
+
4716
+ function disconnectJobsSSE() {
4717
+ if (jobsSSE) {
4718
+ jobsSSE.close();
4719
+ jobsSSE = null;
4720
+ }
4721
+ }
4722
+
4723
+ // Handle tab visibility — close SSE when tab is backgrounded
4724
+ document.addEventListener('visibilitychange', () => {
4725
+ if (document.hidden) {
4726
+ disconnectJobsSSE();
4727
+ } else if (currentTab === 'jobs') {
4728
+ connectJobsSSE();
4729
+ loadJobs(); // Resync on return
4730
+ }
4731
+ // Always refresh vital signs on tab focus
4732
+ if (!document.hidden) pollVitalSigns();
4733
+ });
4734
+
3473
4735
  // Listen for WebSocket paste events
3474
4736
  const origWsOnMessage = ws?.onmessage;
3475
4737
  if (typeof ws !== 'undefined') {