instar 0.23.16 → 0.23.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/index.html +1786 -32
- package/dist/cli.js +299 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +271 -32
- package/dist/commands/server.js.map +1 -1
- package/dist/core/AutoApprover.d.ts +63 -0
- package/dist/core/AutoApprover.d.ts.map +1 -0
- package/dist/core/AutoApprover.js +151 -0
- package/dist/core/AutoApprover.js.map +1 -0
- package/dist/core/AutonomyProfileManager.d.ts +2 -0
- package/dist/core/AutonomyProfileManager.d.ts.map +1 -1
- package/dist/core/AutonomyProfileManager.js +13 -0
- package/dist/core/AutonomyProfileManager.js.map +1 -1
- package/dist/core/CallbackRegistry.d.ts +67 -0
- package/dist/core/CallbackRegistry.d.ts.map +1 -0
- package/dist/core/CallbackRegistry.js +145 -0
- package/dist/core/CallbackRegistry.js.map +1 -0
- package/dist/core/DiscoveryEvaluator.d.ts +131 -0
- package/dist/core/DiscoveryEvaluator.d.ts.map +1 -0
- package/dist/core/DiscoveryEvaluator.js +377 -0
- package/dist/core/DiscoveryEvaluator.js.map +1 -0
- package/dist/core/FeatureDefinitions.d.ts +14 -0
- package/dist/core/FeatureDefinitions.d.ts.map +1 -0
- package/dist/core/FeatureDefinitions.js +374 -0
- package/dist/core/FeatureDefinitions.js.map +1 -0
- package/dist/core/FeatureRegistry.d.ts +337 -0
- package/dist/core/FeatureRegistry.d.ts.map +1 -0
- package/dist/core/FeatureRegistry.js +863 -0
- package/dist/core/FeatureRegistry.js.map +1 -0
- package/dist/core/SessionManager.d.ts +47 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +178 -13
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/SurfacingTemplates.d.ts +63 -0
- package/dist/core/SurfacingTemplates.d.ts.map +1 -0
- package/dist/core/SurfacingTemplates.js +138 -0
- package/dist/core/SurfacingTemplates.js.map +1 -0
- package/dist/core/TopicClassifier.d.ts +54 -0
- package/dist/core/TopicClassifier.d.ts.map +1 -0
- package/dist/core/TopicClassifier.js +144 -0
- package/dist/core/TopicClassifier.js.map +1 -0
- package/dist/core/TopicResumeMap.d.ts +16 -12
- package/dist/core/TopicResumeMap.d.ts.map +1 -1
- package/dist/core/TopicResumeMap.js +42 -43
- package/dist/core/TopicResumeMap.js.map +1 -1
- package/dist/core/types.d.ts +116 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/knowledge/TreeGenerator.d.ts.map +1 -1
- package/dist/knowledge/TreeGenerator.js +14 -0
- package/dist/knowledge/TreeGenerator.js.map +1 -1
- package/dist/lifeline/ServerSupervisor.d.ts +2 -0
- package/dist/lifeline/ServerSupervisor.d.ts.map +1 -1
- package/dist/lifeline/ServerSupervisor.js +4 -1
- package/dist/lifeline/ServerSupervisor.js.map +1 -1
- package/dist/memory/SemanticMemory.d.ts +44 -0
- package/dist/memory/SemanticMemory.d.ts.map +1 -1
- package/dist/memory/SemanticMemory.js +179 -2
- package/dist/memory/SemanticMemory.js.map +1 -1
- package/dist/memory/TopicMemory.d.ts +5 -1
- package/dist/memory/TopicMemory.d.ts.map +1 -1
- package/dist/memory/TopicMemory.js +26 -7
- package/dist/memory/TopicMemory.js.map +1 -1
- package/dist/memory/TopicSummarizer.d.ts +12 -1
- package/dist/memory/TopicSummarizer.d.ts.map +1 -1
- package/dist/memory/TopicSummarizer.js +28 -5
- package/dist/memory/TopicSummarizer.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts +63 -1
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +349 -2
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/monitoring/DegradationReporter.d.ts +6 -0
- package/dist/monitoring/DegradationReporter.d.ts.map +1 -1
- package/dist/monitoring/DegradationReporter.js +26 -3
- package/dist/monitoring/DegradationReporter.js.map +1 -1
- package/dist/monitoring/InputClassifier.d.ts +68 -0
- package/dist/monitoring/InputClassifier.d.ts.map +1 -0
- package/dist/monitoring/InputClassifier.js +243 -0
- package/dist/monitoring/InputClassifier.js.map +1 -0
- package/dist/monitoring/PromptGate.d.ts +74 -0
- package/dist/monitoring/PromptGate.d.ts.map +1 -0
- package/dist/monitoring/PromptGate.js +294 -0
- package/dist/monitoring/PromptGate.js.map +1 -0
- package/dist/monitoring/QuotaNotifier.d.ts.map +1 -1
- package/dist/monitoring/QuotaNotifier.js +11 -4
- package/dist/monitoring/QuotaNotifier.js.map +1 -1
- package/dist/monitoring/SessionWatchdog.d.ts +41 -0
- package/dist/monitoring/SessionWatchdog.d.ts.map +1 -1
- package/dist/monitoring/SessionWatchdog.js +137 -0
- package/dist/monitoring/SessionWatchdog.js.map +1 -1
- package/dist/monitoring/SystemReviewer.js +11 -11
- package/dist/monitoring/SystemReviewer.js.map +1 -1
- package/dist/monitoring/TelemetryAuth.d.ts +64 -0
- package/dist/monitoring/TelemetryAuth.d.ts.map +1 -0
- package/dist/monitoring/TelemetryAuth.js +141 -0
- package/dist/monitoring/TelemetryAuth.js.map +1 -0
- package/dist/monitoring/TelemetryCollector.d.ts +62 -0
- package/dist/monitoring/TelemetryCollector.d.ts.map +1 -0
- package/dist/monitoring/TelemetryCollector.js +291 -0
- package/dist/monitoring/TelemetryCollector.js.map +1 -0
- package/dist/monitoring/TelemetryHeartbeat.d.ts +74 -13
- package/dist/monitoring/TelemetryHeartbeat.d.ts.map +1 -1
- package/dist/monitoring/TelemetryHeartbeat.js +249 -15
- package/dist/monitoring/TelemetryHeartbeat.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +25 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/scheduler/JobScheduler.d.ts +8 -0
- package/dist/scheduler/JobScheduler.d.ts.map +1 -1
- package/dist/scheduler/JobScheduler.js +32 -0
- package/dist/scheduler/JobScheduler.js.map +1 -1
- package/dist/server/AgentServer.d.ts +2 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +5 -1
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/middleware.d.ts +5 -0
- package/dist/server/middleware.d.ts.map +1 -1
- package/dist/server/middleware.js +25 -2
- package/dist/server/middleware.js.map +1 -1
- package/dist/server/routes.d.ts +2 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +934 -11
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/scripts/telemetry-worker/worker.js +593 -19
- package/src/data/builtin-manifest.json +103 -103
- package/src/templates/hooks/compaction-recovery.sh +43 -0
- package/src/templates/hooks/session-start.sh +55 -0
- package/upgrades/0.23.16.md +9 -13
- package/upgrades/0.23.17.md +23 -0
- package/upgrades/0.23.18.md +13 -0
package/dashboard/index.html
CHANGED
|
@@ -1478,6 +1478,711 @@
|
|
|
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
|
+
|
|
1918
|
+
/* ── Discovery Tab ──────────────────────────────────────── */
|
|
1919
|
+
.discovery-container {
|
|
1920
|
+
grid-column: 1 / -1;
|
|
1921
|
+
overflow-y: auto;
|
|
1922
|
+
background: var(--bg);
|
|
1923
|
+
padding: 20px;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
.discovery-main {
|
|
1927
|
+
max-width: 900px;
|
|
1928
|
+
margin: 0 auto;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
.discovery-header {
|
|
1932
|
+
display: flex;
|
|
1933
|
+
align-items: center;
|
|
1934
|
+
justify-content: space-between;
|
|
1935
|
+
margin-bottom: 20px;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
.discovery-header h2 {
|
|
1939
|
+
font-size: 16px;
|
|
1940
|
+
font-weight: 600;
|
|
1941
|
+
color: var(--text-bright);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
.discovery-refresh {
|
|
1945
|
+
font-size: 11px;
|
|
1946
|
+
padding: 4px 12px;
|
|
1947
|
+
border-radius: 4px;
|
|
1948
|
+
border: 1px solid var(--border);
|
|
1949
|
+
background: transparent;
|
|
1950
|
+
color: var(--text-dim);
|
|
1951
|
+
cursor: pointer;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
.discovery-refresh:hover {
|
|
1955
|
+
border-color: var(--text-dim);
|
|
1956
|
+
color: var(--text);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
.discovery-section {
|
|
1960
|
+
margin-bottom: 24px;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
.discovery-section h3 {
|
|
1964
|
+
font-size: 13px;
|
|
1965
|
+
font-weight: 600;
|
|
1966
|
+
color: var(--text-bright);
|
|
1967
|
+
margin-bottom: 12px;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
.funnel-chart {
|
|
1971
|
+
display: flex;
|
|
1972
|
+
gap: 4px;
|
|
1973
|
+
align-items: flex-end;
|
|
1974
|
+
height: 120px;
|
|
1975
|
+
padding: 12px 16px;
|
|
1976
|
+
background: var(--bg-panel);
|
|
1977
|
+
border: 1px solid var(--border);
|
|
1978
|
+
border-radius: 8px;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
.funnel-bar {
|
|
1982
|
+
flex: 1;
|
|
1983
|
+
display: flex;
|
|
1984
|
+
flex-direction: column;
|
|
1985
|
+
align-items: center;
|
|
1986
|
+
gap: 4px;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
.funnel-bar-fill {
|
|
1990
|
+
width: 100%;
|
|
1991
|
+
border-radius: 3px 3px 0 0;
|
|
1992
|
+
min-height: 2px;
|
|
1993
|
+
transition: height 0.3s;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
.funnel-bar-label {
|
|
1997
|
+
font-size: 10px;
|
|
1998
|
+
color: var(--text-dim);
|
|
1999
|
+
text-align: center;
|
|
2000
|
+
white-space: nowrap;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
.funnel-bar-count {
|
|
2004
|
+
font-size: 12px;
|
|
2005
|
+
font-weight: 600;
|
|
2006
|
+
color: var(--text-bright);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
.discovery-filter-bar {
|
|
2010
|
+
display: flex;
|
|
2011
|
+
gap: 4px;
|
|
2012
|
+
margin-bottom: 12px;
|
|
2013
|
+
flex-wrap: wrap;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
.discovery-filter {
|
|
2017
|
+
font-size: 11px;
|
|
2018
|
+
padding: 2px 8px;
|
|
2019
|
+
border-radius: 10px;
|
|
2020
|
+
border: 1px solid var(--border);
|
|
2021
|
+
background: transparent;
|
|
2022
|
+
color: var(--text-dim);
|
|
2023
|
+
cursor: pointer;
|
|
2024
|
+
transition: all 0.15s;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
.discovery-filter:hover {
|
|
2028
|
+
border-color: var(--text-dim);
|
|
2029
|
+
color: var(--text);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
.discovery-filter.active {
|
|
2033
|
+
background: var(--accent-dim);
|
|
2034
|
+
border-color: var(--accent-dim);
|
|
2035
|
+
color: #000;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
.feature-grid {
|
|
2039
|
+
display: grid;
|
|
2040
|
+
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
|
2041
|
+
gap: 10px;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
.feature-card {
|
|
2045
|
+
background: var(--bg-panel);
|
|
2046
|
+
border: 1px solid var(--border);
|
|
2047
|
+
border-radius: 8px;
|
|
2048
|
+
padding: 12px 14px;
|
|
2049
|
+
font-size: 12px;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
.feature-card-top {
|
|
2053
|
+
display: flex;
|
|
2054
|
+
align-items: center;
|
|
2055
|
+
gap: 8px;
|
|
2056
|
+
margin-bottom: 6px;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
.feature-card-name {
|
|
2060
|
+
font-weight: 500;
|
|
2061
|
+
color: var(--text-bright);
|
|
2062
|
+
flex: 1;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
.feature-state-badge {
|
|
2066
|
+
font-size: 10px;
|
|
2067
|
+
padding: 1px 6px;
|
|
2068
|
+
border-radius: 3px;
|
|
2069
|
+
font-weight: 600;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
.feature-state-badge.enabled { background: #0f2f0f; color: var(--accent); }
|
|
2073
|
+
.feature-state-badge.undiscovered { background: #1a1a1a; color: #666; }
|
|
2074
|
+
.feature-state-badge.aware { background: #0f1f2f; color: var(--blue); }
|
|
2075
|
+
.feature-state-badge.interested { background: #1f1f0f; color: #eab308; }
|
|
2076
|
+
.feature-state-badge.deferred { background: #1a1a1a; color: #888; }
|
|
2077
|
+
.feature-state-badge.declined { background: #2f0f0f; color: var(--red); }
|
|
2078
|
+
.feature-state-badge.disabled { background: #1a1a1a; color: #555; }
|
|
2079
|
+
|
|
2080
|
+
.feature-card-desc {
|
|
2081
|
+
color: var(--text-dim);
|
|
2082
|
+
font-size: 11px;
|
|
2083
|
+
line-height: 1.4;
|
|
2084
|
+
margin-bottom: 4px;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
.feature-card-meta {
|
|
2088
|
+
display: flex;
|
|
2089
|
+
gap: 8px;
|
|
2090
|
+
color: var(--text-dim);
|
|
2091
|
+
font-size: 10px;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
.feature-card-meta .tier { text-transform: uppercase; letter-spacing: 0.5px; }
|
|
2095
|
+
.feature-card-meta .cooldown { color: var(--orange); }
|
|
2096
|
+
|
|
2097
|
+
.event-log {
|
|
2098
|
+
background: var(--bg-panel);
|
|
2099
|
+
border: 1px solid var(--border);
|
|
2100
|
+
border-radius: 8px;
|
|
2101
|
+
max-height: 300px;
|
|
2102
|
+
overflow-y: auto;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
.event-row {
|
|
2106
|
+
display: flex;
|
|
2107
|
+
gap: 10px;
|
|
2108
|
+
padding: 8px 14px;
|
|
2109
|
+
border-bottom: 1px solid var(--border);
|
|
2110
|
+
font-size: 11px;
|
|
2111
|
+
align-items: center;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
.event-row:last-child { border-bottom: none; }
|
|
2115
|
+
|
|
2116
|
+
.event-time {
|
|
2117
|
+
color: var(--text-dim);
|
|
2118
|
+
flex-shrink: 0;
|
|
2119
|
+
width: 70px;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
.event-feature {
|
|
2123
|
+
color: var(--text-bright);
|
|
2124
|
+
font-weight: 500;
|
|
2125
|
+
flex-shrink: 0;
|
|
2126
|
+
width: 140px;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
.event-transition {
|
|
2130
|
+
color: var(--text);
|
|
2131
|
+
flex: 1;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
.event-arrow { color: var(--text-dim); }
|
|
2135
|
+
|
|
2136
|
+
.digest-item {
|
|
2137
|
+
background: var(--bg-panel);
|
|
2138
|
+
border: 1px solid var(--border);
|
|
2139
|
+
border-radius: 6px;
|
|
2140
|
+
padding: 10px 14px;
|
|
2141
|
+
margin-bottom: 8px;
|
|
2142
|
+
font-size: 12px;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
.digest-item-title {
|
|
2146
|
+
color: var(--text-bright);
|
|
2147
|
+
font-weight: 500;
|
|
2148
|
+
margin-bottom: 2px;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
.digest-item-desc {
|
|
2152
|
+
color: var(--text-dim);
|
|
2153
|
+
font-size: 11px;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/* Discovery metrics summary */
|
|
2157
|
+
.discovery-metrics {
|
|
2158
|
+
display: flex;
|
|
2159
|
+
gap: 12px;
|
|
2160
|
+
margin-bottom: 16px;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
.metric-card {
|
|
2164
|
+
flex: 1;
|
|
2165
|
+
background: var(--bg-panel);
|
|
2166
|
+
border: 1px solid var(--border);
|
|
2167
|
+
border-radius: 8px;
|
|
2168
|
+
padding: 12px 14px;
|
|
2169
|
+
text-align: center;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
.metric-value {
|
|
2173
|
+
font-size: 22px;
|
|
2174
|
+
font-weight: 600;
|
|
2175
|
+
color: var(--text-bright);
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
.metric-label {
|
|
2179
|
+
font-size: 10px;
|
|
2180
|
+
color: var(--text-dim);
|
|
2181
|
+
text-transform: uppercase;
|
|
2182
|
+
letter-spacing: 0.5px;
|
|
2183
|
+
margin-top: 2px;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
1481
2186
|
/* ── Mobile responsive ─────────────────────────────────── */
|
|
1482
2187
|
@media (max-width: 768px) {
|
|
1483
2188
|
.app {
|
|
@@ -1662,6 +2367,45 @@
|
|
|
1662
2367
|
min-height: 44px;
|
|
1663
2368
|
padding: 10px 16px;
|
|
1664
2369
|
}
|
|
2370
|
+
|
|
2371
|
+
/* Vital signs mobile */
|
|
2372
|
+
.vital-signs {
|
|
2373
|
+
display: none;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
/* Jobs tab mobile */
|
|
2377
|
+
.jobs-container {
|
|
2378
|
+
flex-direction: column;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
.jobs-sidebar {
|
|
2382
|
+
width: 100%;
|
|
2383
|
+
min-width: 100%;
|
|
2384
|
+
border-right: none;
|
|
2385
|
+
border-bottom: 1px solid var(--border);
|
|
2386
|
+
max-height: none;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
.app.jobs-detail-active .jobs-sidebar {
|
|
2390
|
+
display: none;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
.app:not(.jobs-detail-active) .jobs-detail {
|
|
2394
|
+
display: none;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
.job-item {
|
|
2398
|
+
padding: 12px 14px;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
.job-detail-header {
|
|
2402
|
+
flex-direction: column;
|
|
2403
|
+
gap: 10px;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
.jobs-detail-content {
|
|
2407
|
+
padding: 14px;
|
|
2408
|
+
}
|
|
1665
2409
|
}
|
|
1666
2410
|
</style>
|
|
1667
2411
|
</head>
|
|
@@ -1687,8 +2431,31 @@
|
|
|
1687
2431
|
<button class="tab active" data-tab="sessions" onclick="switchTab('sessions')">Sessions <span class="tab-count" id="tabSessionCount">0</span></button>
|
|
1688
2432
|
<button class="tab" data-tab="files" onclick="switchTab('files')">Files</button>
|
|
1689
2433
|
<button class="tab" data-tab="dropzone" onclick="switchTab('dropzone')">Drop Zone</button>
|
|
2434
|
+
<button class="tab" data-tab="jobs" onclick="switchTab('jobs')">Jobs <span class="tab-count" id="tabJobCount">0</span></button>
|
|
2435
|
+
<button class="tab" data-tab="discovery" onclick="switchTab('discovery')">Discovery</button>
|
|
1690
2436
|
</nav>
|
|
1691
2437
|
</div>
|
|
2438
|
+
<div class="vital-signs" id="vitalSigns">
|
|
2439
|
+
<div class="vital" id="vitalServer" onclick="switchTab('jobs')" title="Server status">
|
|
2440
|
+
<span class="vital-dot" id="vitalServerDot" style="background:var(--accent)"></span>
|
|
2441
|
+
<span id="vitalServerText">Healthy</span>
|
|
2442
|
+
</div>
|
|
2443
|
+
<span class="vital-sep"></span>
|
|
2444
|
+
<div class="vital" id="vitalSessions" onclick="switchTab('sessions')" title="Active sessions">
|
|
2445
|
+
<span class="vital-icon">○</span>
|
|
2446
|
+
<span id="vitalSessionsText">0/0</span>
|
|
2447
|
+
</div>
|
|
2448
|
+
<span class="vital-sep"></span>
|
|
2449
|
+
<div class="vital" id="vitalMemory" title="Memory pressure">
|
|
2450
|
+
<span id="vitalMemoryText">Mem 0%</span>
|
|
2451
|
+
<div class="vital-bar"><div class="vital-bar-fill" id="vitalMemoryBar" style="width:0%;background:var(--accent)"></div></div>
|
|
2452
|
+
</div>
|
|
2453
|
+
<span class="vital-sep"></span>
|
|
2454
|
+
<div class="vital" id="vitalJobs" onclick="switchTab('jobs')" title="Failing jobs" style="display:none">
|
|
2455
|
+
<span class="vital-icon" style="color:var(--red)">⚠</span>
|
|
2456
|
+
<span id="vitalJobsText" style="color:var(--red)">0 failing</span>
|
|
2457
|
+
</div>
|
|
2458
|
+
</div>
|
|
1692
2459
|
<button class="wa-status-btn" id="waStatusBtn" onclick="toggleQrPanel()">WhatsApp</button>
|
|
1693
2460
|
<div class="status-badge" id="connStatus">
|
|
1694
2461
|
<span class="dot"></span>
|
|
@@ -1775,7 +2542,6 @@
|
|
|
1775
2542
|
</div>
|
|
1776
2543
|
</div>
|
|
1777
2544
|
</div>
|
|
1778
|
-
</div>
|
|
1779
2545
|
|
|
1780
2546
|
<!-- Toast notifications -->
|
|
1781
2547
|
<div class="toast-container" id="toastContainer"></div>
|
|
@@ -1855,6 +2621,85 @@
|
|
|
1855
2621
|
</div>
|
|
1856
2622
|
</div>
|
|
1857
2623
|
|
|
2624
|
+
<!-- Jobs tab -->
|
|
2625
|
+
<div class="jobs-container" id="jobsTab" style="display:none">
|
|
2626
|
+
<div class="jobs-sidebar">
|
|
2627
|
+
<div class="jobs-sidebar-header">
|
|
2628
|
+
<h2>Jobs</h2>
|
|
2629
|
+
<select class="jobs-sort" id="jobsSort" onchange="renderJobList()">
|
|
2630
|
+
<option value="status">Sort: Status</option>
|
|
2631
|
+
<option value="priority">Sort: Priority</option>
|
|
2632
|
+
<option value="name">Sort: Name</option>
|
|
2633
|
+
<option value="lastRun">Sort: Last Run</option>
|
|
2634
|
+
</select>
|
|
2635
|
+
</div>
|
|
2636
|
+
<div class="jobs-filter-bar" id="jobsFilterBar">
|
|
2637
|
+
<button class="jobs-filter-chip active" data-filter="all" onclick="setJobFilter('all')">All</button>
|
|
2638
|
+
<button class="jobs-filter-chip" data-filter="failing" onclick="setJobFilter('failing')">Failing</button>
|
|
2639
|
+
<button class="jobs-filter-chip" data-filter="disabled" onclick="setJobFilter('disabled')">Disabled</button>
|
|
2640
|
+
</div>
|
|
2641
|
+
<div class="jobs-list" id="jobsList">
|
|
2642
|
+
<div style="padding:20px;color:var(--text-dim);text-align:center">Loading jobs...</div>
|
|
2643
|
+
</div>
|
|
2644
|
+
</div>
|
|
2645
|
+
<div class="jobs-detail" id="jobsDetail">
|
|
2646
|
+
<div class="jobs-detail-empty" id="jobsDetailEmpty">
|
|
2647
|
+
<div style="text-align:center">
|
|
2648
|
+
<div style="font-size:24px;margin-bottom:8px">⚙</div>
|
|
2649
|
+
<p>Select a job to view details</p>
|
|
2650
|
+
</div>
|
|
2651
|
+
</div>
|
|
2652
|
+
<div class="jobs-detail-content" id="jobsDetailContent" style="display:none"></div>
|
|
2653
|
+
</div>
|
|
2654
|
+
</div>
|
|
2655
|
+
|
|
2656
|
+
<!-- Discovery Tab -->
|
|
2657
|
+
<div class="discovery-container" id="discoveryTab" style="display:none">
|
|
2658
|
+
<div class="discovery-main">
|
|
2659
|
+
<div class="discovery-header">
|
|
2660
|
+
<h2>Feature Discovery</h2>
|
|
2661
|
+
<button class="discovery-refresh" onclick="loadDiscovery()">Refresh</button>
|
|
2662
|
+
</div>
|
|
2663
|
+
|
|
2664
|
+
<!-- Funnel Visualization -->
|
|
2665
|
+
<div class="discovery-section">
|
|
2666
|
+
<h3>Discovery Funnel</h3>
|
|
2667
|
+
<div class="funnel-chart" id="funnelChart">
|
|
2668
|
+
<div style="padding:20px;color:var(--text-dim);text-align:center">Loading...</div>
|
|
2669
|
+
</div>
|
|
2670
|
+
</div>
|
|
2671
|
+
|
|
2672
|
+
<!-- Feature States Grid -->
|
|
2673
|
+
<div class="discovery-section">
|
|
2674
|
+
<h3>Feature States</h3>
|
|
2675
|
+
<div class="discovery-filter-bar">
|
|
2676
|
+
<button class="discovery-filter active" data-filter="all" onclick="setDiscoveryFilter('all')">All</button>
|
|
2677
|
+
<button class="discovery-filter" data-filter="enabled" onclick="setDiscoveryFilter('enabled')">Enabled</button>
|
|
2678
|
+
<button class="discovery-filter" data-filter="undiscovered" onclick="setDiscoveryFilter('undiscovered')">Undiscovered</button>
|
|
2679
|
+
<button class="discovery-filter" data-filter="aware" onclick="setDiscoveryFilter('aware')">Aware</button>
|
|
2680
|
+
<button class="discovery-filter" data-filter="cooldown" onclick="setDiscoveryFilter('cooldown')">In Cooldown</button>
|
|
2681
|
+
</div>
|
|
2682
|
+
<div class="feature-grid" id="featureGrid"></div>
|
|
2683
|
+
</div>
|
|
2684
|
+
|
|
2685
|
+
<!-- Digest Section -->
|
|
2686
|
+
<div class="discovery-section" id="digestSection" style="display:none">
|
|
2687
|
+
<h3>Digest</h3>
|
|
2688
|
+
<div id="digestContent"></div>
|
|
2689
|
+
</div>
|
|
2690
|
+
|
|
2691
|
+
<!-- Event Log -->
|
|
2692
|
+
<div class="discovery-section">
|
|
2693
|
+
<h3>Recent Events</h3>
|
|
2694
|
+
<div class="event-log" id="eventLog">
|
|
2695
|
+
<div style="padding:12px;color:var(--text-dim);text-align:center">No events yet</div>
|
|
2696
|
+
</div>
|
|
2697
|
+
</div>
|
|
2698
|
+
</div>
|
|
2699
|
+
</div>
|
|
2700
|
+
|
|
2701
|
+
</div>
|
|
2702
|
+
|
|
1858
2703
|
<!-- WhatsApp QR panel (hidden by default) -->
|
|
1859
2704
|
<div class="wa-qr-backdrop" id="waQrBackdrop" style="display:none" onclick="closeQrPanel()"></div>
|
|
1860
2705
|
<div class="wa-qr-panel" id="waQrPanel" style="display:none">
|
|
@@ -1923,6 +2768,7 @@
|
|
|
1923
2768
|
document.getElementById('app').style.display = 'grid';
|
|
1924
2769
|
connectWebSocket();
|
|
1925
2770
|
startWaPolling();
|
|
2771
|
+
startVitalSigns();
|
|
1926
2772
|
} else {
|
|
1927
2773
|
showAuthError('Incorrect PIN');
|
|
1928
2774
|
}
|
|
@@ -1947,6 +2793,7 @@
|
|
|
1947
2793
|
document.getElementById('app').style.display = 'grid';
|
|
1948
2794
|
connectWebSocket();
|
|
1949
2795
|
startWaPolling();
|
|
2796
|
+
startVitalSigns();
|
|
1950
2797
|
}
|
|
1951
2798
|
})
|
|
1952
2799
|
.catch(() => {});
|
|
@@ -2018,10 +2865,23 @@
|
|
|
2018
2865
|
// No meaningful new content — we've hit the buffer limit
|
|
2019
2866
|
historyExhausted = true;
|
|
2020
2867
|
}
|
|
2868
|
+
// Calculate how many new lines were prepended so we can
|
|
2869
|
+
// restore the user's approximate scroll position after rewrite.
|
|
2870
|
+
const addedLines = Math.max(0, newLineCount - oldLineCount);
|
|
2871
|
+
const buf = term.buffer.active;
|
|
2872
|
+
const prevViewportY = buf.viewportY;
|
|
2873
|
+
|
|
2021
2874
|
// Rewrite terminal with expanded history
|
|
2022
2875
|
term.clear();
|
|
2023
2876
|
term.write(msg.data);
|
|
2024
|
-
|
|
2877
|
+
|
|
2878
|
+
// Restore scroll position: shift down by the number of new lines
|
|
2879
|
+
// so the user sees the same content they were looking at.
|
|
2880
|
+
requestAnimationFrame(() => {
|
|
2881
|
+
const newY = prevViewportY + addedLines;
|
|
2882
|
+
term.scrollToLine(Math.min(newY, term.buffer.active.baseY));
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2025
2885
|
historyLoading = false;
|
|
2026
2886
|
hideHistorySpinner();
|
|
2027
2887
|
}
|
|
@@ -2172,6 +3032,11 @@
|
|
|
2172
3032
|
}
|
|
2173
3033
|
|
|
2174
3034
|
activeSession = tmuxSession;
|
|
3035
|
+
userIsFollowing = true; // Reset scroll tracking on session switch
|
|
3036
|
+
historyLinesLoaded = 2000; // Reset history state for new session
|
|
3037
|
+
historyLoading = false;
|
|
3038
|
+
historyExhausted = false;
|
|
3039
|
+
hideHistorySpinner();
|
|
2175
3040
|
|
|
2176
3041
|
// Mobile: show terminal, hide sidebar
|
|
2177
3042
|
document.getElementById('app').classList.add('terminal-active');
|
|
@@ -2248,7 +3113,7 @@
|
|
|
2248
3113
|
fontFamily: "'SF Mono', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace",
|
|
2249
3114
|
cursorBlink: false,
|
|
2250
3115
|
cursorStyle: 'underline',
|
|
2251
|
-
scrollback:
|
|
3116
|
+
scrollback: 50000,
|
|
2252
3117
|
convertEol: true,
|
|
2253
3118
|
});
|
|
2254
3119
|
|
|
@@ -2266,17 +3131,154 @@
|
|
|
2266
3131
|
requestAnimationFrame(() => {
|
|
2267
3132
|
try { fitAddon.fit(); } catch {}
|
|
2268
3133
|
});
|
|
3134
|
+
|
|
3135
|
+
// Track user scroll position for auto-follow behavior.
|
|
3136
|
+
// When user scrolls up, stop auto-following. When they scroll
|
|
3137
|
+
// back near the bottom, resume following.
|
|
3138
|
+
// Also trigger history loading when user scrolls near the top.
|
|
3139
|
+
term.onScroll(() => {
|
|
3140
|
+
const buf = term.buffer.active;
|
|
3141
|
+
const atBottom = buf.baseY === 0 || (buf.baseY - buf.viewportY) <= 3;
|
|
3142
|
+
|
|
3143
|
+
if (atBottom && !userIsFollowing) {
|
|
3144
|
+
// User scrolled back to bottom — resume following and apply pending output
|
|
3145
|
+
userIsFollowing = true;
|
|
3146
|
+
if (pendingOutputData) {
|
|
3147
|
+
const data = pendingOutputData;
|
|
3148
|
+
pendingOutputData = null;
|
|
3149
|
+
term.clear();
|
|
3150
|
+
term.write(data);
|
|
3151
|
+
term.scrollToBottom();
|
|
3152
|
+
}
|
|
3153
|
+
} else if (!atBottom) {
|
|
3154
|
+
userIsFollowing = false;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
// Infinite scroll: load more history when scrolled near the top
|
|
3158
|
+
if (buf.viewportY <= 10 && !historyLoading && !historyExhausted && activeSession) {
|
|
3159
|
+
loadMoreHistory();
|
|
3160
|
+
}
|
|
3161
|
+
});
|
|
3162
|
+
|
|
3163
|
+
// Also listen on the xterm viewport DOM element for wheel events.
|
|
3164
|
+
// term.onScroll doesn't fire when the user is already at the scroll
|
|
3165
|
+
// boundary — the wheel event lets us detect "trying to scroll past the top."
|
|
3166
|
+
const xtermViewport = container.querySelector('.xterm-viewport');
|
|
3167
|
+
if (xtermViewport) {
|
|
3168
|
+
xtermViewport.addEventListener('wheel', (e) => {
|
|
3169
|
+
// Scrolling up while near top → load more history
|
|
3170
|
+
if (e.deltaY < 0) {
|
|
3171
|
+
const buf = term.buffer.active;
|
|
3172
|
+
if (buf.viewportY <= 10 && !historyLoading && !historyExhausted && activeSession) {
|
|
3173
|
+
loadMoreHistory();
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
// Scrolling down — check if we hit bottom to resume
|
|
3177
|
+
if (e.deltaY > 0 && !userIsFollowing) {
|
|
3178
|
+
const buf = term.buffer.active;
|
|
3179
|
+
const atBottom = buf.baseY === 0 || (buf.baseY - buf.viewportY) <= 5;
|
|
3180
|
+
if (atBottom) {
|
|
3181
|
+
userIsFollowing = true;
|
|
3182
|
+
if (pendingOutputData) {
|
|
3183
|
+
const data = pendingOutputData;
|
|
3184
|
+
pendingOutputData = null;
|
|
3185
|
+
term.clear();
|
|
3186
|
+
term.write(data);
|
|
3187
|
+
term.scrollToBottom();
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
}, { passive: true });
|
|
3192
|
+
}
|
|
2269
3193
|
}
|
|
2270
3194
|
|
|
3195
|
+
/** Request more terminal history from the server */
|
|
3196
|
+
function loadMoreHistory() {
|
|
3197
|
+
if (historyLoading || historyExhausted || !activeSession) return;
|
|
3198
|
+
const nextBatch = Math.min(historyLinesLoaded + 5000, 50000);
|
|
3199
|
+
if (nextBatch <= historyLinesLoaded) {
|
|
3200
|
+
historyExhausted = true;
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
historyLoading = true;
|
|
3204
|
+
showHistorySpinner();
|
|
3205
|
+
wsSend({ type: 'history', session: activeSession, lines: nextBatch });
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
function showHistorySpinner() {
|
|
3209
|
+
let spinner = document.getElementById('historySpinner');
|
|
3210
|
+
if (!spinner) {
|
|
3211
|
+
spinner = document.createElement('div');
|
|
3212
|
+
spinner.id = 'historySpinner';
|
|
3213
|
+
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;';
|
|
3214
|
+
spinner.textContent = 'Loading history…';
|
|
3215
|
+
const container = document.getElementById('terminalContainer');
|
|
3216
|
+
if (container) container.style.position = 'relative';
|
|
3217
|
+
container?.appendChild(spinner);
|
|
3218
|
+
}
|
|
3219
|
+
spinner.style.display = 'block';
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
function hideHistorySpinner() {
|
|
3223
|
+
const spinner = document.getElementById('historySpinner');
|
|
3224
|
+
if (spinner) spinner.style.display = 'none';
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
/** Track whether user is near the bottom of terminal output */
|
|
3228
|
+
let userIsFollowing = true;
|
|
3229
|
+
/** Pending output data received while user is scrolled up — applied when they return to bottom */
|
|
3230
|
+
let pendingOutputData = null;
|
|
3231
|
+
|
|
2271
3232
|
function renderTerminalOutput(data) {
|
|
2272
3233
|
if (!term) return;
|
|
2273
|
-
|
|
3234
|
+
|
|
3235
|
+
// If user is scrolled up reading history, DON'T rewrite the terminal.
|
|
3236
|
+
// Cache the latest data and apply it when they scroll back to the bottom.
|
|
3237
|
+
// This prevents the "jumping around" problem caused by clear()+write() every 500ms.
|
|
3238
|
+
if (!userIsFollowing) {
|
|
3239
|
+
pendingOutputData = data;
|
|
3240
|
+
showResumeButton();
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
// User is following — apply the update
|
|
3245
|
+
hideResumeButton();
|
|
2274
3246
|
term.clear();
|
|
2275
3247
|
term.write(data);
|
|
2276
|
-
// Auto-scroll to bottom
|
|
2277
3248
|
term.scrollToBottom();
|
|
2278
3249
|
}
|
|
2279
3250
|
|
|
3251
|
+
/** Show a "Resume live output" button when updates are paused */
|
|
3252
|
+
function showResumeButton() {
|
|
3253
|
+
let btn = document.getElementById('resumeBtn');
|
|
3254
|
+
if (!btn) {
|
|
3255
|
+
btn = document.createElement('button');
|
|
3256
|
+
btn.id = 'resumeBtn';
|
|
3257
|
+
btn.textContent = '▼ Resume live output';
|
|
3258
|
+
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);';
|
|
3259
|
+
btn.onclick = () => {
|
|
3260
|
+
userIsFollowing = true;
|
|
3261
|
+
if (pendingOutputData) {
|
|
3262
|
+
const data = pendingOutputData;
|
|
3263
|
+
pendingOutputData = null;
|
|
3264
|
+
term.clear();
|
|
3265
|
+
term.write(data);
|
|
3266
|
+
term.scrollToBottom();
|
|
3267
|
+
}
|
|
3268
|
+
hideResumeButton();
|
|
3269
|
+
};
|
|
3270
|
+
const container = document.getElementById('terminalContainer');
|
|
3271
|
+
if (container) container.style.position = 'relative';
|
|
3272
|
+
container?.appendChild(btn);
|
|
3273
|
+
}
|
|
3274
|
+
btn.style.display = 'block';
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
function hideResumeButton() {
|
|
3278
|
+
const btn = document.getElementById('resumeBtn');
|
|
3279
|
+
if (btn) btn.style.display = 'none';
|
|
3280
|
+
}
|
|
3281
|
+
|
|
2280
3282
|
function goBack() {
|
|
2281
3283
|
// Mobile: go back to session list
|
|
2282
3284
|
if (activeSession) {
|
|
@@ -2570,11 +3572,57 @@
|
|
|
2570
3572
|
}
|
|
2571
3573
|
});
|
|
2572
3574
|
|
|
2573
|
-
// ── Tab System
|
|
3575
|
+
// ── Tab System (Data-Driven Registry) ──────────────────────
|
|
2574
3576
|
let currentTab = 'sessions';
|
|
2575
3577
|
|
|
3578
|
+
const TAB_REGISTRY = [
|
|
3579
|
+
{
|
|
3580
|
+
id: 'sessions',
|
|
3581
|
+
panels: ['sessionsTab', 'mainPanel'],
|
|
3582
|
+
display: ['', ''],
|
|
3583
|
+
onActivate: null,
|
|
3584
|
+
},
|
|
3585
|
+
{
|
|
3586
|
+
id: 'files',
|
|
3587
|
+
panels: ['filesTab'],
|
|
3588
|
+
display: ['flex'],
|
|
3589
|
+
onActivate: () => { if (!fileTreeLoaded) loadFileTree(); },
|
|
3590
|
+
},
|
|
3591
|
+
{
|
|
3592
|
+
id: 'dropzone',
|
|
3593
|
+
panels: ['dropzoneTab'],
|
|
3594
|
+
display: ['flex'],
|
|
3595
|
+
onActivate: () => {
|
|
3596
|
+
setTimeout(() => document.getElementById('dzContent')?.focus(), 100);
|
|
3597
|
+
loadDzSessions();
|
|
3598
|
+
loadDzHistory();
|
|
3599
|
+
},
|
|
3600
|
+
},
|
|
3601
|
+
{
|
|
3602
|
+
id: 'jobs',
|
|
3603
|
+
panels: ['jobsTab'],
|
|
3604
|
+
display: ['flex'],
|
|
3605
|
+
onActivate: () => {
|
|
3606
|
+
if (!jobsLoaded) loadJobs();
|
|
3607
|
+
connectJobsSSE();
|
|
3608
|
+
},
|
|
3609
|
+
onDeactivate: () => { disconnectJobsSSE(); },
|
|
3610
|
+
},
|
|
3611
|
+
{
|
|
3612
|
+
id: 'discovery',
|
|
3613
|
+
panels: ['discoveryTab'],
|
|
3614
|
+
display: ['flex'],
|
|
3615
|
+
onActivate: () => { if (!discoveryLoaded) loadDiscovery(); },
|
|
3616
|
+
},
|
|
3617
|
+
];
|
|
3618
|
+
|
|
2576
3619
|
function switchTab(tabName) {
|
|
2577
3620
|
if (tabName === currentTab) return;
|
|
3621
|
+
|
|
3622
|
+
// Deactivate current tab
|
|
3623
|
+
const prevTab = TAB_REGISTRY.find(t => t.id === currentTab);
|
|
3624
|
+
if (prevTab?.onDeactivate) prevTab.onDeactivate();
|
|
3625
|
+
|
|
2578
3626
|
currentTab = tabName;
|
|
2579
3627
|
|
|
2580
3628
|
// Update tab buttons
|
|
@@ -2582,34 +3630,24 @@
|
|
|
2582
3630
|
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
|
2583
3631
|
});
|
|
2584
3632
|
|
|
2585
|
-
//
|
|
2586
|
-
const
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
// Load file tree on first switch
|
|
2602
|
-
if (!fileTreeLoaded) loadFileTree();
|
|
2603
|
-
} else if (tabName === 'dropzone') {
|
|
2604
|
-
dropzoneTab.style.display = 'flex';
|
|
2605
|
-
// Auto-focus textarea
|
|
2606
|
-
setTimeout(() => document.getElementById('dzContent')?.focus(), 100);
|
|
2607
|
-
// Load session list and paste history
|
|
2608
|
-
loadDzSessions();
|
|
2609
|
-
loadDzHistory();
|
|
3633
|
+
// Hide all panels
|
|
3634
|
+
for (const tab of TAB_REGISTRY) {
|
|
3635
|
+
for (const panelId of tab.panels) {
|
|
3636
|
+
const el = document.getElementById(panelId);
|
|
3637
|
+
if (el) el.style.display = 'none';
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
// Show active tab panels
|
|
3642
|
+
const activeTab = TAB_REGISTRY.find(t => t.id === tabName);
|
|
3643
|
+
if (activeTab) {
|
|
3644
|
+
activeTab.panels.forEach((panelId, i) => {
|
|
3645
|
+
const el = document.getElementById(panelId);
|
|
3646
|
+
if (el) el.style.display = activeTab.display[i] || '';
|
|
3647
|
+
});
|
|
3648
|
+
if (activeTab.onActivate) activeTab.onActivate();
|
|
2610
3649
|
}
|
|
2611
3650
|
|
|
2612
|
-
// Update URL
|
|
2613
3651
|
updateFileUrl();
|
|
2614
3652
|
}
|
|
2615
3653
|
|
|
@@ -3493,6 +4531,722 @@
|
|
|
3493
4531
|
return d.innerHTML;
|
|
3494
4532
|
}
|
|
3495
4533
|
|
|
4534
|
+
// ── Vital Signs Strip ──────────────────────────────────────
|
|
4535
|
+
let vitalSignsInterval = null;
|
|
4536
|
+
|
|
4537
|
+
function startVitalSigns() {
|
|
4538
|
+
pollVitalSigns();
|
|
4539
|
+
vitalSignsInterval = setInterval(pollVitalSigns, 30000);
|
|
4540
|
+
}
|
|
4541
|
+
|
|
4542
|
+
async function pollVitalSigns() {
|
|
4543
|
+
try {
|
|
4544
|
+
const resp = await fetch('/health', { headers: { 'Authorization': 'Bearer ' + token } });
|
|
4545
|
+
if (!resp.ok) return;
|
|
4546
|
+
const h = await resp.json();
|
|
4547
|
+
|
|
4548
|
+
// Server status
|
|
4549
|
+
const serverDot = document.getElementById('vitalServerDot');
|
|
4550
|
+
const serverText = document.getElementById('vitalServerText');
|
|
4551
|
+
const serverVital = document.getElementById('vitalServer');
|
|
4552
|
+
if (h.status === 'ok') {
|
|
4553
|
+
serverDot.style.background = 'var(--accent)';
|
|
4554
|
+
serverText.textContent = h.uptimeHuman || 'Healthy';
|
|
4555
|
+
serverVital.className = 'vital';
|
|
4556
|
+
} else {
|
|
4557
|
+
serverDot.style.background = 'var(--orange)';
|
|
4558
|
+
serverText.textContent = 'Degraded';
|
|
4559
|
+
serverVital.className = 'vital warn';
|
|
4560
|
+
}
|
|
4561
|
+
|
|
4562
|
+
// Sessions
|
|
4563
|
+
const sessEl = document.getElementById('vitalSessionsText');
|
|
4564
|
+
const sessVital = document.getElementById('vitalSessions');
|
|
4565
|
+
const cur = h.sessions?.current ?? 0;
|
|
4566
|
+
const max = h.sessions?.max ?? 0;
|
|
4567
|
+
sessEl.textContent = cur + '/' + max;
|
|
4568
|
+
if (cur >= max) {
|
|
4569
|
+
sessVital.className = 'vital warn';
|
|
4570
|
+
} else {
|
|
4571
|
+
sessVital.className = 'vital';
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4574
|
+
// Memory
|
|
4575
|
+
const memPct = h.memoryPressure?.pressurePercent ?? h.systemMemory?.usedPercent ?? 0;
|
|
4576
|
+
const memText = document.getElementById('vitalMemoryText');
|
|
4577
|
+
const memBar = document.getElementById('vitalMemoryBar');
|
|
4578
|
+
const memVital = document.getElementById('vitalMemory');
|
|
4579
|
+
memText.textContent = 'Mem ' + Math.round(memPct) + '%';
|
|
4580
|
+
memBar.style.width = Math.min(memPct, 100) + '%';
|
|
4581
|
+
if (memPct >= 75) {
|
|
4582
|
+
memBar.style.background = 'var(--red)';
|
|
4583
|
+
memVital.className = 'vital crit';
|
|
4584
|
+
} else if (memPct >= 60) {
|
|
4585
|
+
memBar.style.background = 'var(--orange)';
|
|
4586
|
+
memVital.className = 'vital warn';
|
|
4587
|
+
} else {
|
|
4588
|
+
memBar.style.background = 'var(--accent)';
|
|
4589
|
+
memVital.className = 'vital';
|
|
4590
|
+
}
|
|
4591
|
+
|
|
4592
|
+
// Failing jobs
|
|
4593
|
+
const failing = h.jobs?.failing || [];
|
|
4594
|
+
const jobsVital = document.getElementById('vitalJobs');
|
|
4595
|
+
const jobsText = document.getElementById('vitalJobsText');
|
|
4596
|
+
if (failing.length > 0) {
|
|
4597
|
+
jobsVital.style.display = 'flex';
|
|
4598
|
+
jobsText.textContent = failing.length + ' failing';
|
|
4599
|
+
} else {
|
|
4600
|
+
jobsVital.style.display = 'none';
|
|
4601
|
+
}
|
|
4602
|
+
|
|
4603
|
+
// Update tab badge
|
|
4604
|
+
const jobCount = document.getElementById('tabJobCount');
|
|
4605
|
+
if (jobCount) {
|
|
4606
|
+
const total = h.jobs?.total ?? 0;
|
|
4607
|
+
if (failing.length > 0) {
|
|
4608
|
+
jobCount.textContent = failing.length;
|
|
4609
|
+
jobCount.style.background = 'var(--red)';
|
|
4610
|
+
jobCount.style.color = '#fff';
|
|
4611
|
+
} else {
|
|
4612
|
+
jobCount.textContent = total;
|
|
4613
|
+
jobCount.style.background = '';
|
|
4614
|
+
jobCount.style.color = '';
|
|
4615
|
+
}
|
|
4616
|
+
}
|
|
4617
|
+
} catch (e) {
|
|
4618
|
+
// Vital signs polling failure is silent
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
|
|
4622
|
+
// ── Jobs Tab ─────────────────────────────────────────────────
|
|
4623
|
+
let jobsLoaded = false;
|
|
4624
|
+
let jobsData = [];
|
|
4625
|
+
let selectedJob = null;
|
|
4626
|
+
let jobFilter = 'all';
|
|
4627
|
+
let jobsSSE = null;
|
|
4628
|
+
|
|
4629
|
+
const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
4630
|
+
|
|
4631
|
+
function cronToHuman(cron) {
|
|
4632
|
+
try {
|
|
4633
|
+
if (!cron || typeof cron !== 'string') return cron || '—';
|
|
4634
|
+
const parts = cron.trim().split(/\s+/);
|
|
4635
|
+
if (parts.length < 5) return cron;
|
|
4636
|
+
const [min, hour, dom, mon, dow] = parts;
|
|
4637
|
+
|
|
4638
|
+
// */N * * * * → Every N minutes
|
|
4639
|
+
if (/^\*\/(\d+)$/.test(min) && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
|
|
4640
|
+
const n = parseInt(min.slice(2));
|
|
4641
|
+
return n === 1 ? 'Every minute' : 'Every ' + n + ' min';
|
|
4642
|
+
}
|
|
4643
|
+
// 0 */N * * * → Every N hours
|
|
4644
|
+
if (min === '0' && /^\*\/(\d+)$/.test(hour) && dom === '*' && mon === '*' && dow === '*') {
|
|
4645
|
+
const n = parseInt(hour.slice(2));
|
|
4646
|
+
return n === 1 ? 'Every hour' : 'Every ' + n + 'h';
|
|
4647
|
+
}
|
|
4648
|
+
// 0 N * * * → Daily at N:00
|
|
4649
|
+
if (/^\d+$/.test(min) && /^\d+$/.test(hour) && dom === '*' && mon === '*' && dow === '*') {
|
|
4650
|
+
const h = parseInt(hour);
|
|
4651
|
+
const m = parseInt(min);
|
|
4652
|
+
const time = (h % 12 || 12) + (m ? ':' + String(m).padStart(2, '0') : '') + (h >= 12 ? ' PM' : ' AM');
|
|
4653
|
+
return 'Daily at ' + time;
|
|
4654
|
+
}
|
|
4655
|
+
// 0 N * * D → Day at N:00
|
|
4656
|
+
if (/^\d+$/.test(min) && /^\d+$/.test(hour) && dom === '*' && mon === '*' && /^\d+$/.test(dow)) {
|
|
4657
|
+
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
4658
|
+
const h = parseInt(hour);
|
|
4659
|
+
const time = (h % 12 || 12) + (h >= 12 ? ' PM' : ' AM');
|
|
4660
|
+
return (days[parseInt(dow)] || dow) + ' at ' + time;
|
|
4661
|
+
}
|
|
4662
|
+
// * * * * * → Every minute
|
|
4663
|
+
if (min === '*' && hour === '*') return 'Every minute';
|
|
4664
|
+
// 0 * * * * → Every hour
|
|
4665
|
+
if (min === '0' && hour === '*') return 'Every hour';
|
|
4666
|
+
return cron;
|
|
4667
|
+
} catch {
|
|
4668
|
+
return cron;
|
|
4669
|
+
}
|
|
4670
|
+
}
|
|
4671
|
+
|
|
4672
|
+
async function loadJobs() {
|
|
4673
|
+
jobsLoaded = true;
|
|
4674
|
+
try {
|
|
4675
|
+
const data = await apiFetch('/jobs');
|
|
4676
|
+
const jobs = data.jobs || data;
|
|
4677
|
+
jobsData = Array.isArray(jobs) ? jobs : [];
|
|
4678
|
+
renderJobList();
|
|
4679
|
+
} catch (e) {
|
|
4680
|
+
document.getElementById('jobsList').innerHTML =
|
|
4681
|
+
'<div style="padding:20px;color:var(--red);text-align:center">Failed to load jobs</div>';
|
|
4682
|
+
}
|
|
4683
|
+
}
|
|
4684
|
+
|
|
4685
|
+
function setJobFilter(filter) {
|
|
4686
|
+
jobFilter = filter;
|
|
4687
|
+
document.querySelectorAll('.jobs-filter-chip').forEach(c => {
|
|
4688
|
+
c.classList.toggle('active', c.dataset.filter === filter);
|
|
4689
|
+
});
|
|
4690
|
+
renderJobList();
|
|
4691
|
+
}
|
|
4692
|
+
|
|
4693
|
+
function renderJobList() {
|
|
4694
|
+
const list = document.getElementById('jobsList');
|
|
4695
|
+
const sort = document.getElementById('jobsSort').value;
|
|
4696
|
+
|
|
4697
|
+
let filtered = jobsData.filter(j => {
|
|
4698
|
+
if (jobFilter === 'failing') {
|
|
4699
|
+
const s = j.state || {};
|
|
4700
|
+
return (s.consecutiveFailures || 0) > 0;
|
|
4701
|
+
}
|
|
4702
|
+
if (jobFilter === 'disabled') return !j.enabled;
|
|
4703
|
+
return true;
|
|
4704
|
+
});
|
|
4705
|
+
|
|
4706
|
+
// Sort
|
|
4707
|
+
filtered.sort((a, b) => {
|
|
4708
|
+
const sa = a.state || {};
|
|
4709
|
+
const sb = b.state || {};
|
|
4710
|
+
if (sort === 'status') {
|
|
4711
|
+
const fa = sa.consecutiveFailures || 0;
|
|
4712
|
+
const fb = sb.consecutiveFailures || 0;
|
|
4713
|
+
if (fb !== fa) return fb - fa; // Failing first
|
|
4714
|
+
return (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2);
|
|
4715
|
+
}
|
|
4716
|
+
if (sort === 'priority') {
|
|
4717
|
+
return (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2);
|
|
4718
|
+
}
|
|
4719
|
+
if (sort === 'name') return (a.slug || '').localeCompare(b.slug || '');
|
|
4720
|
+
if (sort === 'lastRun') {
|
|
4721
|
+
const ta = sa.lastRun ? new Date(sa.lastRun).getTime() : 0;
|
|
4722
|
+
const tb = sb.lastRun ? new Date(sb.lastRun).getTime() : 0;
|
|
4723
|
+
return tb - ta;
|
|
4724
|
+
}
|
|
4725
|
+
return 0;
|
|
4726
|
+
});
|
|
4727
|
+
|
|
4728
|
+
if (filtered.length === 0) {
|
|
4729
|
+
list.innerHTML = '<div style="padding:20px;color:var(--text-dim);text-align:center">No jobs match filter</div>';
|
|
4730
|
+
return;
|
|
4731
|
+
}
|
|
4732
|
+
|
|
4733
|
+
list.innerHTML = filtered.map(j => {
|
|
4734
|
+
const s = j.state || {};
|
|
4735
|
+
const failures = s.consecutiveFailures || 0;
|
|
4736
|
+
const isRunning = s.lastResult === 'pending';
|
|
4737
|
+
let dotClass = 'healthy';
|
|
4738
|
+
if (!j.enabled) dotClass = 'disabled';
|
|
4739
|
+
else if (isRunning) dotClass = 'running';
|
|
4740
|
+
else if (failures >= 3) dotClass = 'failing';
|
|
4741
|
+
else if (failures > 0) dotClass = 'warn';
|
|
4742
|
+
|
|
4743
|
+
const schedule = cronToHuman(j.schedule);
|
|
4744
|
+
const lastResult = s.lastResult || '—';
|
|
4745
|
+
const lastTime = s.lastRun ? timeAgo(new Date(s.lastRun)) : 'never';
|
|
4746
|
+
const isActive = selectedJob === j.slug ? ' active' : '';
|
|
4747
|
+
|
|
4748
|
+
const priorityBadge = (j.priority === 'critical' || j.priority === 'high')
|
|
4749
|
+
? '<span class="priority-badge ' + esc(j.priority) + '">' + esc(j.priority) + '</span>' : '';
|
|
4750
|
+
|
|
4751
|
+
return '<div class="job-item' + isActive + '" onclick="selectJob(\'' + esc(j.slug) + '\')">'
|
|
4752
|
+
+ '<div class="job-item-top">'
|
|
4753
|
+
+ '<span class="job-status-dot ' + dotClass + '"></span>'
|
|
4754
|
+
+ '<span class="job-item-name">' + esc(j.slug) + '</span>'
|
|
4755
|
+
+ (failures > 0 ? '<span class="job-failure-count">' + failures + ' fail</span>' : '')
|
|
4756
|
+
+ '</div>'
|
|
4757
|
+
+ '<div class="job-item-meta">'
|
|
4758
|
+
+ '<span>' + esc(schedule) + '</span>'
|
|
4759
|
+
+ '<span class="model-badge">' + esc(j.model || '—') + '</span>'
|
|
4760
|
+
+ priorityBadge
|
|
4761
|
+
+ '</div>'
|
|
4762
|
+
+ '<div class="job-item-status">'
|
|
4763
|
+
+ '<span>' + esc(lastResult) + '</span> · <span>' + esc(lastTime) + '</span>'
|
|
4764
|
+
+ '</div>'
|
|
4765
|
+
+ '</div>';
|
|
4766
|
+
}).join('');
|
|
4767
|
+
}
|
|
4768
|
+
|
|
4769
|
+
async function selectJob(slug) {
|
|
4770
|
+
selectedJob = slug;
|
|
4771
|
+
renderJobList(); // Update active highlight
|
|
4772
|
+
|
|
4773
|
+
const job = jobsData.find(j => j.slug === slug);
|
|
4774
|
+
if (!job) return;
|
|
4775
|
+
|
|
4776
|
+
const detail = document.getElementById('jobsDetailContent');
|
|
4777
|
+
const empty = document.getElementById('jobsDetailEmpty');
|
|
4778
|
+
empty.style.display = 'none';
|
|
4779
|
+
detail.style.display = '';
|
|
4780
|
+
|
|
4781
|
+
// Mobile: show detail
|
|
4782
|
+
document.getElementById('app').classList.add('jobs-detail-active');
|
|
4783
|
+
|
|
4784
|
+
const s = job.state || {};
|
|
4785
|
+
const failures = s.consecutiveFailures || 0;
|
|
4786
|
+
const schedule = cronToHuman(job.schedule);
|
|
4787
|
+
const lastResult = s.lastResult || '—';
|
|
4788
|
+
const lastTime = s.lastRun ? timeAgo(new Date(s.lastRun)) : 'never';
|
|
4789
|
+
const nextTime = s.nextScheduled ? timeAgo(new Date(s.nextScheduled)).replace(' ago', '') : '—';
|
|
4790
|
+
const isRunning = s.lastResult === 'pending';
|
|
4791
|
+
|
|
4792
|
+
let statusText = 'Healthy';
|
|
4793
|
+
let statusClass = 'success';
|
|
4794
|
+
if (!job.enabled) { statusText = 'Disabled'; statusClass = ''; }
|
|
4795
|
+
else if (failures >= 3) { statusText = 'Failing (' + failures + ' consecutive)'; statusClass = 'error'; }
|
|
4796
|
+
else if (failures > 0) { statusText = 'Warning (' + failures + ' failures)'; statusClass = 'error'; }
|
|
4797
|
+
else if (isRunning) { statusText = 'Running'; statusClass = ''; }
|
|
4798
|
+
|
|
4799
|
+
const tags = (job.tags || []).map(t => esc(t)).join(', ') || '—';
|
|
4800
|
+
|
|
4801
|
+
detail.innerHTML = '<div class="job-detail-header">'
|
|
4802
|
+
+ '<div>'
|
|
4803
|
+
+ '<button class="back-btn" onclick="jobsGoBack()" style="display:none;margin-right:8px">←</button>'
|
|
4804
|
+
+ '<h3>' + esc(job.slug) + '</h3>'
|
|
4805
|
+
+ '<div class="job-desc">' + esc(job.description || job.name || '') + '</div>'
|
|
4806
|
+
+ '<div class="job-meta-line">'
|
|
4807
|
+
+ '<span>' + esc(job.schedule) + ' (' + esc(schedule) + ')</span>'
|
|
4808
|
+
+ '<span class="model-badge">' + esc(job.model || '—') + '</span>'
|
|
4809
|
+
+ (job.priority ? '<span class="priority-badge ' + esc(job.priority) + '">' + esc(job.priority) + '</span>' : '')
|
|
4810
|
+
+ '<span>Tags: ' + tags + '</span>'
|
|
4811
|
+
+ '</div></div>'
|
|
4812
|
+
+ '<div class="job-detail-actions">'
|
|
4813
|
+
+ '<button class="job-run-btn" id="jobRunBtn" onclick="runJob(\'' + esc(job.slug) + '\')"'
|
|
4814
|
+
+ (isRunning ? ' disabled' : '') + '>'
|
|
4815
|
+
+ (isRunning ? 'Running...' : 'Run Now') + '</button>'
|
|
4816
|
+
+ '<button class="job-toggle' + (job.enabled ? ' enabled' : '') + '" id="jobToggle" '
|
|
4817
|
+
+ 'onclick="toggleJob(\'' + esc(job.slug) + '\', ' + (!job.enabled) + ')" '
|
|
4818
|
+
+ 'title="' + (job.enabled ? 'Disable' : 'Enable') + '"></button>'
|
|
4819
|
+
+ '</div></div>'
|
|
4820
|
+
+ '<div class="job-state-card">'
|
|
4821
|
+
+ '<div class="state-row"><span>Status</span><span class="state-val ' + statusClass + '">' + esc(statusText) + '</span></div>'
|
|
4822
|
+
+ '<div class="state-row"><span>Last Run</span><span class="state-val">' + esc(lastTime) + '</span></div>'
|
|
4823
|
+
+ '<div class="state-row"><span>Last Result</span><span class="state-val ' + (lastResult === 'success' ? 'success' : failures > 0 ? 'error' : '') + '">' + esc(lastResult) + '</span></div>'
|
|
4824
|
+
+ (s.lastError ? '<div class="state-row"><span>Error</span><span class="state-val error">' + esc(s.lastError) + '</span></div>' : '')
|
|
4825
|
+
+ '<div class="state-row"><span>Next Run</span><span class="state-val">' + esc(nextTime) + '</span></div>'
|
|
4826
|
+
+ '</div>'
|
|
4827
|
+
+ '<div id="jobSparkline" class="job-sparkline"></div>'
|
|
4828
|
+
+ '<div class="job-history-section"><h4>Run History</h4>'
|
|
4829
|
+
+ '<div id="jobHistoryTable">Loading...</div></div>';
|
|
4830
|
+
|
|
4831
|
+
// Show back button on mobile
|
|
4832
|
+
if (window.innerWidth <= 768) {
|
|
4833
|
+
detail.querySelector('.back-btn').style.display = 'inline-block';
|
|
4834
|
+
}
|
|
4835
|
+
|
|
4836
|
+
// Load history
|
|
4837
|
+
loadJobHistory(slug);
|
|
4838
|
+
}
|
|
4839
|
+
|
|
4840
|
+
function jobsGoBack() {
|
|
4841
|
+
document.getElementById('app').classList.remove('jobs-detail-active');
|
|
4842
|
+
selectedJob = null;
|
|
4843
|
+
document.getElementById('jobsDetailContent').style.display = 'none';
|
|
4844
|
+
document.getElementById('jobsDetailEmpty').style.display = '';
|
|
4845
|
+
renderJobList();
|
|
4846
|
+
}
|
|
4847
|
+
|
|
4848
|
+
async function loadJobHistory(slug) {
|
|
4849
|
+
try {
|
|
4850
|
+
const data = await apiFetch('/jobs/' + encodeURIComponent(slug) + '/history?limit=50');
|
|
4851
|
+
const runs = data.runs || [];
|
|
4852
|
+
const table = document.getElementById('jobHistoryTable');
|
|
4853
|
+
const sparkline = document.getElementById('jobSparkline');
|
|
4854
|
+
|
|
4855
|
+
// Sparkline
|
|
4856
|
+
if (runs.length > 0) {
|
|
4857
|
+
sparkline.innerHTML = runs.slice().reverse().slice(-50).map(r => {
|
|
4858
|
+
const cls = 's-' + (r.result || 'pending');
|
|
4859
|
+
const title = (r.result || '?') + ' — ' + new Date(r.startedAt || r.completedAt).toLocaleTimeString();
|
|
4860
|
+
return '<div class="spark ' + cls + '" title="' + esc(title) + '"></div>';
|
|
4861
|
+
}).join('');
|
|
4862
|
+
}
|
|
4863
|
+
|
|
4864
|
+
if (runs.length === 0) {
|
|
4865
|
+
table.innerHTML = '<div style="color:var(--text-dim)">No run history</div>';
|
|
4866
|
+
return;
|
|
4867
|
+
}
|
|
4868
|
+
|
|
4869
|
+
table.innerHTML = '<table class="job-history-table">'
|
|
4870
|
+
+ '<thead><tr><th>Time</th><th>Result</th><th>Duration</th><th>Error</th></tr></thead>'
|
|
4871
|
+
+ '<tbody>' + runs.slice(0, 100).map(r => {
|
|
4872
|
+
const time = r.startedAt ? new Date(r.startedAt).toLocaleTimeString() : '—';
|
|
4873
|
+
const result = r.result || '—';
|
|
4874
|
+
const dur = r.durationSeconds != null ? r.durationSeconds + 's'
|
|
4875
|
+
: r.durationMs != null ? Math.round(r.durationMs / 1000) + 's' : '—';
|
|
4876
|
+
const error = r.error ? esc(r.error).substring(0, 80) : '—';
|
|
4877
|
+
return '<tr>'
|
|
4878
|
+
+ '<td>' + esc(time) + '</td>'
|
|
4879
|
+
+ '<td><span class="result-badge ' + esc(result) + '">' + esc(result) + '</span></td>'
|
|
4880
|
+
+ '<td>' + esc(dur) + '</td>'
|
|
4881
|
+
+ '<td style="color:var(--text-dim);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + error + '</td>'
|
|
4882
|
+
+ '</tr>';
|
|
4883
|
+
}).join('') + '</tbody></table>';
|
|
4884
|
+
} catch (e) {
|
|
4885
|
+
document.getElementById('jobHistoryTable').innerHTML =
|
|
4886
|
+
'<div style="color:var(--red)">Failed to load history</div>';
|
|
4887
|
+
}
|
|
4888
|
+
}
|
|
4889
|
+
|
|
4890
|
+
// ── Run Now / Enable-Disable ─────────────────────────────────
|
|
4891
|
+
|
|
4892
|
+
async function runJob(slug) {
|
|
4893
|
+
const btn = document.getElementById('jobRunBtn');
|
|
4894
|
+
if (!btn) return;
|
|
4895
|
+
btn.disabled = true;
|
|
4896
|
+
btn.classList.add('running');
|
|
4897
|
+
btn.textContent = 'Running...';
|
|
4898
|
+
const startTime = Date.now();
|
|
4899
|
+
|
|
4900
|
+
// Update elapsed timer
|
|
4901
|
+
const timer = setInterval(() => {
|
|
4902
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
4903
|
+
btn.textContent = 'Running... ' + elapsed + 's';
|
|
4904
|
+
}, 1000);
|
|
4905
|
+
|
|
4906
|
+
try {
|
|
4907
|
+
const resp = await fetch('/jobs/' + encodeURIComponent(slug) + '/run', {
|
|
4908
|
+
method: 'POST',
|
|
4909
|
+
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
4910
|
+
});
|
|
4911
|
+
const data = await resp.json();
|
|
4912
|
+
|
|
4913
|
+
if (resp.status === 409) {
|
|
4914
|
+
clearInterval(timer);
|
|
4915
|
+
btn.textContent = 'Already running';
|
|
4916
|
+
setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; btn.classList.remove('running'); }, 3000);
|
|
4917
|
+
return;
|
|
4918
|
+
}
|
|
4919
|
+
if (resp.status === 429) {
|
|
4920
|
+
clearInterval(timer);
|
|
4921
|
+
btn.textContent = 'Rate limited';
|
|
4922
|
+
setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; btn.classList.remove('running'); }, 3000);
|
|
4923
|
+
return;
|
|
4924
|
+
}
|
|
4925
|
+
if (!resp.ok) {
|
|
4926
|
+
clearInterval(timer);
|
|
4927
|
+
btn.textContent = 'Error';
|
|
4928
|
+
setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; btn.classList.remove('running'); }, 3000);
|
|
4929
|
+
return;
|
|
4930
|
+
}
|
|
4931
|
+
|
|
4932
|
+
// Poll for completion (SSE handles live updates, but poll as fallback)
|
|
4933
|
+
const runId = data.runId;
|
|
4934
|
+
let attempts = 0;
|
|
4935
|
+
const pollCompletion = setInterval(async () => {
|
|
4936
|
+
attempts++;
|
|
4937
|
+
if (attempts > 60) { // 120s timeout
|
|
4938
|
+
clearInterval(pollCompletion);
|
|
4939
|
+
clearInterval(timer);
|
|
4940
|
+
btn.textContent = 'Still running...';
|
|
4941
|
+
setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; btn.classList.remove('running'); }, 3000);
|
|
4942
|
+
return;
|
|
4943
|
+
}
|
|
4944
|
+
try {
|
|
4945
|
+
await loadJobs(); // Refresh job states
|
|
4946
|
+
const job = jobsData.find(j => j.slug === slug);
|
|
4947
|
+
if (job && job.state && job.state.lastResult !== 'pending') {
|
|
4948
|
+
clearInterval(pollCompletion);
|
|
4949
|
+
clearInterval(timer);
|
|
4950
|
+
btn.classList.remove('running');
|
|
4951
|
+
btn.textContent = job.state.lastResult === 'success' ? 'Done!' : job.state.lastResult;
|
|
4952
|
+
if (selectedJob === slug) loadJobHistory(slug);
|
|
4953
|
+
setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; }, 3000);
|
|
4954
|
+
}
|
|
4955
|
+
} catch {}
|
|
4956
|
+
}, 2000);
|
|
4957
|
+
} catch (e) {
|
|
4958
|
+
clearInterval(timer);
|
|
4959
|
+
btn.textContent = 'Error';
|
|
4960
|
+
setTimeout(() => { btn.textContent = 'Run Now'; btn.disabled = false; btn.classList.remove('running'); }, 3000);
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4963
|
+
|
|
4964
|
+
async function toggleJob(slug, enabled) {
|
|
4965
|
+
// Confirmation for critical/high priority jobs
|
|
4966
|
+
const job = jobsData.find(j => j.slug === slug);
|
|
4967
|
+
if (job && !enabled && (job.priority === 'critical' || job.priority === 'high')) {
|
|
4968
|
+
if (!confirm('Disable ' + slug + '? This is a ' + job.priority + '-priority job.')) return;
|
|
4969
|
+
}
|
|
4970
|
+
|
|
4971
|
+
try {
|
|
4972
|
+
const resp = await fetch('/jobs/' + encodeURIComponent(slug), {
|
|
4973
|
+
method: 'PATCH',
|
|
4974
|
+
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
4975
|
+
body: JSON.stringify({ enabled }),
|
|
4976
|
+
});
|
|
4977
|
+
if (resp.ok) {
|
|
4978
|
+
await loadJobs();
|
|
4979
|
+
if (selectedJob === slug) selectJob(slug);
|
|
4980
|
+
}
|
|
4981
|
+
} catch {}
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4984
|
+
// ── Jobs SSE ─────────────────────────────────────────────────
|
|
4985
|
+
|
|
4986
|
+
function connectJobsSSE() {
|
|
4987
|
+
if (jobsSSE) return; // Already connected
|
|
4988
|
+
try {
|
|
4989
|
+
// EventSource doesn't support custom headers, so pass token as query param
|
|
4990
|
+
jobsSSE = new EventSource('/jobs/events?token=' + encodeURIComponent(token));
|
|
4991
|
+
|
|
4992
|
+
jobsSSE.addEventListener('snapshot', (e) => {
|
|
4993
|
+
try {
|
|
4994
|
+
const data = JSON.parse(e.data);
|
|
4995
|
+
if (data.jobs) {
|
|
4996
|
+
// Merge SSE snapshot into existing jobs data
|
|
4997
|
+
for (const sj of data.jobs) {
|
|
4998
|
+
const existing = jobsData.find(j => j.slug === sj.slug);
|
|
4999
|
+
if (existing && sj.state) {
|
|
5000
|
+
existing.state = sj.state;
|
|
5001
|
+
if (sj.enabled !== undefined) existing.enabled = sj.enabled;
|
|
5002
|
+
}
|
|
5003
|
+
}
|
|
5004
|
+
renderJobList();
|
|
5005
|
+
if (selectedJob) {
|
|
5006
|
+
const job = jobsData.find(j => j.slug === selectedJob);
|
|
5007
|
+
if (job) selectJob(selectedJob);
|
|
5008
|
+
}
|
|
5009
|
+
}
|
|
5010
|
+
} catch {}
|
|
5011
|
+
});
|
|
5012
|
+
|
|
5013
|
+
jobsSSE.addEventListener('job-state', (e) => {
|
|
5014
|
+
try {
|
|
5015
|
+
const data = JSON.parse(e.data);
|
|
5016
|
+
const job = jobsData.find(j => j.slug === data.slug);
|
|
5017
|
+
if (job && data.state) {
|
|
5018
|
+
job.state = data.state;
|
|
5019
|
+
renderJobList();
|
|
5020
|
+
if (selectedJob === data.slug) selectJob(data.slug);
|
|
5021
|
+
}
|
|
5022
|
+
} catch {}
|
|
5023
|
+
});
|
|
5024
|
+
|
|
5025
|
+
jobsSSE.onerror = () => {
|
|
5026
|
+
// SSE will auto-reconnect, but clean up if tab switches away
|
|
5027
|
+
if (currentTab !== 'jobs') {
|
|
5028
|
+
disconnectJobsSSE();
|
|
5029
|
+
}
|
|
5030
|
+
};
|
|
5031
|
+
} catch {
|
|
5032
|
+
// SSE not supported or connection failed — fall back to polling
|
|
5033
|
+
jobsSSE = null;
|
|
5034
|
+
}
|
|
5035
|
+
}
|
|
5036
|
+
|
|
5037
|
+
function disconnectJobsSSE() {
|
|
5038
|
+
if (jobsSSE) {
|
|
5039
|
+
jobsSSE.close();
|
|
5040
|
+
jobsSSE = null;
|
|
5041
|
+
}
|
|
5042
|
+
}
|
|
5043
|
+
|
|
5044
|
+
// ── Discovery Tab ──────────────────────────────────────
|
|
5045
|
+
let discoveryLoaded = false;
|
|
5046
|
+
let discoveryData = null;
|
|
5047
|
+
let discoveryFilter = 'all';
|
|
5048
|
+
|
|
5049
|
+
const FUNNEL_COLORS = {
|
|
5050
|
+
undiscovered: '#444',
|
|
5051
|
+
aware: 'var(--blue)',
|
|
5052
|
+
interested: '#eab308',
|
|
5053
|
+
deferred: '#888',
|
|
5054
|
+
declined: 'var(--red)',
|
|
5055
|
+
enabled: 'var(--accent)',
|
|
5056
|
+
disabled: '#555',
|
|
5057
|
+
};
|
|
5058
|
+
|
|
5059
|
+
const FUNNEL_ORDER = ['undiscovered', 'aware', 'interested', 'deferred', 'declined', 'enabled', 'disabled'];
|
|
5060
|
+
|
|
5061
|
+
async function loadDiscovery() {
|
|
5062
|
+
discoveryLoaded = true;
|
|
5063
|
+
try {
|
|
5064
|
+
discoveryData = await apiFetch('/features/analytics');
|
|
5065
|
+
renderDiscoveryMetrics();
|
|
5066
|
+
renderFunnel();
|
|
5067
|
+
renderFeatureGrid();
|
|
5068
|
+
renderDigest();
|
|
5069
|
+
renderEventLog();
|
|
5070
|
+
} catch (e) {
|
|
5071
|
+
document.getElementById('funnelChart').innerHTML =
|
|
5072
|
+
'<div style="padding:20px;color:var(--red);text-align:center">Failed to load discovery data</div>';
|
|
5073
|
+
}
|
|
5074
|
+
}
|
|
5075
|
+
|
|
5076
|
+
function renderDiscoveryMetrics() {
|
|
5077
|
+
if (!discoveryData) return;
|
|
5078
|
+
const d = discoveryData;
|
|
5079
|
+
const metricsHtml = `
|
|
5080
|
+
<div class="discovery-metrics">
|
|
5081
|
+
<div class="metric-card">
|
|
5082
|
+
<div class="metric-value">${d.totalFeatures}</div>
|
|
5083
|
+
<div class="metric-label">Total Features</div>
|
|
5084
|
+
</div>
|
|
5085
|
+
<div class="metric-card">
|
|
5086
|
+
<div class="metric-value">${d.enabledCount}</div>
|
|
5087
|
+
<div class="metric-label">Enabled</div>
|
|
5088
|
+
</div>
|
|
5089
|
+
<div class="metric-card">
|
|
5090
|
+
<div class="metric-value">${Math.round(d.discoveryRate * 100)}%</div>
|
|
5091
|
+
<div class="metric-label">Discovery Rate</div>
|
|
5092
|
+
</div>
|
|
5093
|
+
<div class="metric-card">
|
|
5094
|
+
<div class="metric-value">${d.cooldowns.filter(c => c.cooldownExpiresAt).length}</div>
|
|
5095
|
+
<div class="metric-label">In Cooldown</div>
|
|
5096
|
+
</div>
|
|
5097
|
+
</div>`;
|
|
5098
|
+
// Insert before funnel
|
|
5099
|
+
const section = document.querySelector('.discovery-section');
|
|
5100
|
+
if (section && !document.getElementById('discoveryMetrics')) {
|
|
5101
|
+
const div = document.createElement('div');
|
|
5102
|
+
div.id = 'discoveryMetrics';
|
|
5103
|
+
div.innerHTML = metricsHtml;
|
|
5104
|
+
section.parentNode.insertBefore(div, section);
|
|
5105
|
+
}
|
|
5106
|
+
}
|
|
5107
|
+
|
|
5108
|
+
function renderFunnel() {
|
|
5109
|
+
if (!discoveryData) return;
|
|
5110
|
+
const funnel = discoveryData.funnel;
|
|
5111
|
+
const maxVal = Math.max(1, ...Object.values(funnel));
|
|
5112
|
+
const chart = document.getElementById('funnelChart');
|
|
5113
|
+
|
|
5114
|
+
chart.innerHTML = FUNNEL_ORDER.map(state => {
|
|
5115
|
+
const count = funnel[state] || 0;
|
|
5116
|
+
const height = Math.max(2, (count / maxVal) * 80);
|
|
5117
|
+
const color = FUNNEL_COLORS[state] || '#444';
|
|
5118
|
+
return `<div class="funnel-bar">
|
|
5119
|
+
<div class="funnel-bar-count">${count}</div>
|
|
5120
|
+
<div class="funnel-bar-fill" style="height:${height}px;background:${color}"></div>
|
|
5121
|
+
<div class="funnel-bar-label">${state}</div>
|
|
5122
|
+
</div>`;
|
|
5123
|
+
}).join('');
|
|
5124
|
+
}
|
|
5125
|
+
|
|
5126
|
+
function setDiscoveryFilter(filter) {
|
|
5127
|
+
discoveryFilter = filter;
|
|
5128
|
+
document.querySelectorAll('.discovery-filter').forEach(c => {
|
|
5129
|
+
c.classList.toggle('active', c.dataset.filter === filter);
|
|
5130
|
+
});
|
|
5131
|
+
renderFeatureGrid();
|
|
5132
|
+
}
|
|
5133
|
+
|
|
5134
|
+
function renderFeatureGrid() {
|
|
5135
|
+
if (!discoveryData) return;
|
|
5136
|
+
const grid = document.getElementById('featureGrid');
|
|
5137
|
+
const cooldowns = discoveryData.cooldowns || [];
|
|
5138
|
+
|
|
5139
|
+
let filtered = cooldowns;
|
|
5140
|
+
if (discoveryFilter === 'enabled') {
|
|
5141
|
+
filtered = cooldowns.filter(c => c.discoveryState === 'enabled');
|
|
5142
|
+
} else if (discoveryFilter === 'undiscovered') {
|
|
5143
|
+
filtered = cooldowns.filter(c => c.discoveryState === 'undiscovered');
|
|
5144
|
+
} else if (discoveryFilter === 'aware') {
|
|
5145
|
+
filtered = cooldowns.filter(c => ['aware', 'interested', 'deferred'].includes(c.discoveryState));
|
|
5146
|
+
} else if (discoveryFilter === 'cooldown') {
|
|
5147
|
+
filtered = cooldowns.filter(c => c.cooldownExpiresAt || c.quieted);
|
|
5148
|
+
}
|
|
5149
|
+
|
|
5150
|
+
if (filtered.length === 0) {
|
|
5151
|
+
grid.innerHTML = '<div style="padding:16px;color:var(--text-dim);text-align:center">No features match filter</div>';
|
|
5152
|
+
return;
|
|
5153
|
+
}
|
|
5154
|
+
|
|
5155
|
+
grid.innerHTML = filtered.map(c => {
|
|
5156
|
+
const cooldownNote = c.cooldownExpiresAt
|
|
5157
|
+
? '<span class="cooldown">cooldown until ' + new Date(c.cooldownExpiresAt).toLocaleTimeString() + '</span>'
|
|
5158
|
+
: c.quieted ? '<span class="cooldown">quieted (' + c.surfaceCount + '/' + c.maxSurfaces + ')</span>' : '';
|
|
5159
|
+
|
|
5160
|
+
return `<div class="feature-card">
|
|
5161
|
+
<div class="feature-card-top">
|
|
5162
|
+
<span class="feature-card-name">${esc(c.featureName)}</span>
|
|
5163
|
+
<span class="feature-state-badge ${esc(c.discoveryState)}">${esc(c.discoveryState)}</span>
|
|
5164
|
+
</div>
|
|
5165
|
+
<div class="feature-card-meta">
|
|
5166
|
+
<span>surfaced ${c.surfaceCount}/${c.maxSurfaces}</span>
|
|
5167
|
+
${cooldownNote}
|
|
5168
|
+
</div>
|
|
5169
|
+
</div>`;
|
|
5170
|
+
}).join('');
|
|
5171
|
+
}
|
|
5172
|
+
|
|
5173
|
+
function renderDigest() {
|
|
5174
|
+
if (!discoveryData) return;
|
|
5175
|
+
const section = document.getElementById('digestSection');
|
|
5176
|
+
const content = document.getElementById('digestContent');
|
|
5177
|
+
const changed = discoveryData.changedDisabled || [];
|
|
5178
|
+
const unused = discoveryData.unusedEnabled || [];
|
|
5179
|
+
|
|
5180
|
+
if (changed.length === 0 && unused.length === 0) {
|
|
5181
|
+
section.style.display = 'none';
|
|
5182
|
+
return;
|
|
5183
|
+
}
|
|
5184
|
+
|
|
5185
|
+
section.style.display = '';
|
|
5186
|
+
let html = '';
|
|
5187
|
+
|
|
5188
|
+
if (changed.length > 0) {
|
|
5189
|
+
html += '<h4 style="font-size:12px;color:var(--text-dim);margin-bottom:8px">Disabled features with updates</h4>';
|
|
5190
|
+
html += changed.map(f =>
|
|
5191
|
+
`<div class="digest-item">
|
|
5192
|
+
<div class="digest-item-title">${esc(f.featureName)}</div>
|
|
5193
|
+
<div class="digest-item-desc">Version ${esc(f.currentVersion)} available</div>
|
|
5194
|
+
</div>`
|
|
5195
|
+
).join('');
|
|
5196
|
+
}
|
|
5197
|
+
|
|
5198
|
+
if (unused.length > 0) {
|
|
5199
|
+
html += '<h4 style="font-size:12px;color:var(--text-dim);margin:12px 0 8px">Enabled but unused (>15 days)</h4>';
|
|
5200
|
+
html += unused.map(f =>
|
|
5201
|
+
`<div class="digest-item">
|
|
5202
|
+
<div class="digest-item-title">${esc(f.featureName)}</div>
|
|
5203
|
+
<div class="digest-item-desc">${f.daysSinceActivity} days since last activity</div>
|
|
5204
|
+
</div>`
|
|
5205
|
+
).join('');
|
|
5206
|
+
}
|
|
5207
|
+
|
|
5208
|
+
content.innerHTML = html;
|
|
5209
|
+
}
|
|
5210
|
+
|
|
5211
|
+
function renderEventLog() {
|
|
5212
|
+
if (!discoveryData) return;
|
|
5213
|
+
const log = document.getElementById('eventLog');
|
|
5214
|
+
const events = discoveryData.recentEvents || [];
|
|
5215
|
+
|
|
5216
|
+
if (events.length === 0) {
|
|
5217
|
+
log.innerHTML = '<div style="padding:12px;color:var(--text-dim);text-align:center">No events yet</div>';
|
|
5218
|
+
return;
|
|
5219
|
+
}
|
|
5220
|
+
|
|
5221
|
+
log.innerHTML = events.slice(0, 30).map(e => {
|
|
5222
|
+
const time = new Date(e.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
5223
|
+
const date = new Date(e.timestamp).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
5224
|
+
const surfaceLabel = e.surfacedAs ? ` (${e.surfacedAs})` : '';
|
|
5225
|
+
return `<div class="event-row">
|
|
5226
|
+
<span class="event-time">${esc(date)} ${esc(time)}</span>
|
|
5227
|
+
<span class="event-feature">${esc(e.featureId)}</span>
|
|
5228
|
+
<span class="event-transition">
|
|
5229
|
+
<span class="feature-state-badge ${esc(e.previousState)}">${esc(e.previousState)}</span>
|
|
5230
|
+
<span class="event-arrow">→</span>
|
|
5231
|
+
<span class="feature-state-badge ${esc(e.newState)}">${esc(e.newState)}</span>
|
|
5232
|
+
${surfaceLabel}
|
|
5233
|
+
</span>
|
|
5234
|
+
</div>`;
|
|
5235
|
+
}).join('');
|
|
5236
|
+
}
|
|
5237
|
+
|
|
5238
|
+
// Handle tab visibility — close SSE when tab is backgrounded
|
|
5239
|
+
document.addEventListener('visibilitychange', () => {
|
|
5240
|
+
if (document.hidden) {
|
|
5241
|
+
disconnectJobsSSE();
|
|
5242
|
+
} else if (currentTab === 'jobs') {
|
|
5243
|
+
connectJobsSSE();
|
|
5244
|
+
loadJobs(); // Resync on return
|
|
5245
|
+
}
|
|
5246
|
+
// Always refresh vital signs on tab focus
|
|
5247
|
+
if (!document.hidden) pollVitalSigns();
|
|
5248
|
+
});
|
|
5249
|
+
|
|
3496
5250
|
// Listen for WebSocket paste events
|
|
3497
5251
|
const origWsOnMessage = ws?.onmessage;
|
|
3498
5252
|
if (typeof ws !== 'undefined') {
|