@openqa/cli 2.1.1 → 2.1.2

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.
@@ -5588,672 +5588,860 @@ function getEnvHTML() {
5588
5588
  <head>
5589
5589
  <meta charset="UTF-8">
5590
5590
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
5591
- <title>Environment Variables - OpenQA</title>
5591
+ <title>OpenQA \u2014 Environment</title>
5592
+ <link rel="preconnect" href="https://fonts.googleapis.com">
5593
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
5592
5594
  <style>
5593
- * { margin: 0; padding: 0; box-sizing: border-box; }
5594
-
5595
+ :root {
5596
+ --bg: #080b10;
5597
+ --surface: #0d1117;
5598
+ --panel: #111720;
5599
+ --border: rgba(255,255,255,0.06);
5600
+ --border-hi: rgba(255,255,255,0.12);
5601
+ --accent: #f97316;
5602
+ --accent-lo: rgba(249,115,22,0.08);
5603
+ --accent-md: rgba(249,115,22,0.18);
5604
+ --green: #22c55e;
5605
+ --green-lo: rgba(34,197,94,0.08);
5606
+ --red: #ef4444;
5607
+ --red-lo: rgba(239,68,68,0.08);
5608
+ --amber: #f59e0b;
5609
+ --amber-lo: rgba(245,158,11,0.08);
5610
+ --blue: #38bdf8;
5611
+ --blue-lo: rgba(56,189,248,0.08);
5612
+ --text-1: #f1f5f9;
5613
+ --text-2: #8b98a8;
5614
+ --text-3: #4b5563;
5615
+ --mono: 'DM Mono', monospace;
5616
+ --sans: 'Syne', sans-serif;
5617
+ --radius: 10px;
5618
+ --radius-lg: 16px;
5619
+ }
5620
+
5621
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
5622
+
5595
5623
  body {
5596
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
5597
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
5624
+ font-family: var(--sans);
5625
+ background: var(--bg);
5626
+ color: var(--text-1);
5598
5627
  min-height: 100vh;
5599
- padding: 20px;
5628
+ overflow-x: hidden;
5600
5629
  }
5601
5630
 
5602
- .container {
5603
- max-width: 1200px;
5604
- margin: 0 auto;
5631
+ /* \u2500\u2500 Layout \u2500\u2500 */
5632
+ .shell {
5633
+ display: grid;
5634
+ grid-template-columns: 220px 1fr;
5635
+ min-height: 100vh;
5605
5636
  }
5606
5637
 
5607
- .header {
5608
- background: rgba(255, 255, 255, 0.95);
5609
- backdrop-filter: blur(10px);
5610
- padding: 20px 30px;
5611
- border-radius: 12px;
5612
- margin-bottom: 20px;
5613
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
5638
+ /* \u2500\u2500 Sidebar \u2500\u2500 */
5639
+ aside {
5640
+ background: var(--surface);
5641
+ border-right: 1px solid var(--border);
5614
5642
  display: flex;
5615
- justify-content: space-between;
5616
- align-items: center;
5643
+ flex-direction: column;
5644
+ padding: 28px 0;
5645
+ position: sticky;
5646
+ top: 0;
5647
+ height: 100vh;
5617
5648
  }
5618
5649
 
5619
- .header h1 {
5620
- font-size: 24px;
5621
- color: #1a202c;
5650
+ .logo {
5622
5651
  display: flex;
5623
5652
  align-items: center;
5624
5653
  gap: 10px;
5654
+ padding: 0 24px 32px;
5655
+ border-bottom: 1px solid var(--border);
5656
+ margin-bottom: 12px;
5625
5657
  }
5626
5658
 
5627
- .header-actions {
5628
- display: flex;
5629
- gap: 10px;
5659
+ .logo-mark {
5660
+ width: 34px; height: 34px;
5661
+ background: var(--accent);
5662
+ border-radius: 8px;
5663
+ display: grid;
5664
+ place-items: center;
5665
+ font-size: 17px;
5666
+ font-weight: 800;
5667
+ color: #fff;
5630
5668
  }
5631
5669
 
5632
- .btn {
5633
- padding: 10px 20px;
5634
- border: none;
5635
- border-radius: 8px;
5670
+ .logo-name { font-weight: 800; font-size: 18px; letter-spacing: -0.5px; }
5671
+ .logo-version { font-family: var(--mono); font-size: 10px; color: var(--text-3); }
5672
+
5673
+ .nav-section { padding: 8px 12px; flex: 1; overflow-y: auto; }
5674
+
5675
+ .nav-label {
5676
+ font-family: var(--mono);
5677
+ font-size: 10px;
5678
+ color: var(--text-3);
5679
+ letter-spacing: 1.5px;
5680
+ text-transform: uppercase;
5681
+ padding: 0 12px;
5682
+ margin: 16px 0 6px;
5683
+ }
5684
+
5685
+ .nav-item {
5686
+ display: flex;
5687
+ align-items: center;
5688
+ gap: 10px;
5689
+ padding: 9px 12px;
5690
+ border-radius: var(--radius);
5691
+ color: var(--text-2);
5692
+ text-decoration: none;
5636
5693
  font-size: 14px;
5637
5694
  font-weight: 600;
5695
+ transition: all 0.15s ease;
5638
5696
  cursor: pointer;
5639
- transition: all 0.2s;
5640
- text-decoration: none;
5641
- display: inline-flex;
5642
- align-items: center;
5643
- gap: 8px;
5644
5697
  }
5698
+ .nav-item:hover { color: var(--text-1); background: var(--panel); }
5699
+ .nav-item.active { color: var(--accent); background: var(--accent-lo); }
5700
+ .nav-item .icon { font-size: 15px; width: 20px; text-align: center; }
5645
5701
 
5646
- .btn-primary {
5647
- background: #667eea;
5648
- color: white;
5702
+ .sidebar-footer {
5703
+ padding: 16px 24px;
5704
+ border-top: 1px solid var(--border);
5649
5705
  }
5650
5706
 
5651
- .btn-primary:hover {
5652
- background: #5568d3;
5653
- transform: translateY(-1px);
5654
- }
5707
+ /* \u2500\u2500 Main \u2500\u2500 */
5708
+ main { display: flex; flex-direction: column; min-height: 100vh; overflow-y: auto; }
5655
5709
 
5656
- .btn-secondary {
5657
- background: #e2e8f0;
5658
- color: #4a5568;
5710
+ .topbar {
5711
+ display: flex;
5712
+ align-items: center;
5713
+ justify-content: space-between;
5714
+ padding: 20px 32px;
5715
+ border-bottom: 1px solid var(--border);
5716
+ background: var(--surface);
5717
+ position: sticky;
5718
+ top: 0;
5719
+ z-index: 10;
5659
5720
  }
5660
5721
 
5661
- .btn-secondary:hover {
5662
- background: #cbd5e0;
5663
- }
5722
+ .page-title { font-size: 15px; font-weight: 700; letter-spacing: -0.2px; }
5723
+ .page-sub { font-family: var(--mono); font-size: 11px; color: var(--text-3); margin-top: 2px; }
5664
5724
 
5665
- .btn-success {
5666
- background: #48bb78;
5667
- color: white;
5668
- }
5725
+ .topbar-actions { display: flex; align-items: center; gap: 10px; }
5669
5726
 
5670
- .btn-success:hover {
5671
- background: #38a169;
5727
+ .btn {
5728
+ font-family: var(--sans);
5729
+ font-weight: 700;
5730
+ font-size: 12px;
5731
+ padding: 8px 16px;
5732
+ border-radius: 8px;
5733
+ border: none;
5734
+ cursor: pointer;
5735
+ transition: all 0.15s ease;
5736
+ display: inline-flex;
5737
+ align-items: center;
5738
+ gap: 6px;
5739
+ text-decoration: none;
5672
5740
  }
5741
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
5673
5742
 
5674
- .btn:disabled {
5675
- opacity: 0.5;
5676
- cursor: not-allowed;
5743
+ .btn-ghost {
5744
+ background: var(--panel);
5745
+ color: var(--text-2);
5746
+ border: 1px solid var(--border);
5677
5747
  }
5748
+ .btn-ghost:hover { border-color: var(--border-hi); color: var(--text-1); }
5678
5749
 
5679
- .content {
5680
- background: rgba(255, 255, 255, 0.95);
5681
- backdrop-filter: blur(10px);
5682
- padding: 30px;
5683
- border-radius: 12px;
5684
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
5750
+ .btn-primary {
5751
+ background: var(--accent);
5752
+ color: #fff;
5685
5753
  }
5754
+ .btn-primary:hover:not(:disabled) { background: #ea580c; box-shadow: 0 0 20px rgba(249,115,22,0.35); }
5686
5755
 
5687
- .tabs {
5756
+ /* \u2500\u2500 Content \u2500\u2500 */
5757
+ .content { padding: 28px 32px; display: flex; flex-direction: column; gap: 24px; }
5758
+
5759
+ /* \u2500\u2500 Tabs (category selector) \u2500\u2500 */
5760
+ .tab-bar {
5688
5761
  display: flex;
5689
- gap: 10px;
5690
- margin-bottom: 30px;
5691
- border-bottom: 2px solid #e2e8f0;
5692
- padding-bottom: 10px;
5762
+ gap: 4px;
5763
+ background: var(--surface);
5764
+ border: 1px solid var(--border);
5765
+ border-radius: 10px;
5766
+ padding: 4px;
5767
+ flex-wrap: wrap;
5693
5768
  }
5694
5769
 
5695
- .tab {
5696
- padding: 10px 20px;
5770
+ .tab-btn {
5771
+ padding: 7px 14px;
5772
+ background: transparent;
5697
5773
  border: none;
5698
- background: none;
5699
- font-size: 14px;
5774
+ border-radius: 7px;
5775
+ color: var(--text-3);
5776
+ font-family: var(--sans);
5777
+ font-size: 12px;
5700
5778
  font-weight: 600;
5701
- color: #718096;
5702
5779
  cursor: pointer;
5703
- border-bottom: 3px solid transparent;
5704
- transition: all 0.2s;
5705
- }
5706
-
5707
- .tab.active {
5708
- color: #667eea;
5709
- border-bottom-color: #667eea;
5780
+ transition: all 0.15s ease;
5781
+ white-space: nowrap;
5782
+ display: flex;
5783
+ align-items: center;
5784
+ gap: 5px;
5710
5785
  }
5711
-
5712
- .tab:hover {
5713
- color: #667eea;
5786
+ .tab-btn:hover { color: var(--text-2); }
5787
+ .tab-btn.active {
5788
+ background: var(--panel);
5789
+ color: var(--text-1);
5790
+ border: 1px solid var(--border-hi);
5714
5791
  }
5715
-
5716
- .category-section {
5717
- display: none;
5792
+ .tab-btn .tab-dot {
5793
+ width: 6px; height: 6px;
5794
+ border-radius: 50%;
5795
+ background: var(--text-3);
5718
5796
  }
5797
+ .tab-btn.has-required .tab-dot { background: var(--amber); }
5798
+ .tab-btn.active .tab-dot { background: var(--accent); }
5719
5799
 
5720
- .category-section.active {
5721
- display: block;
5722
- }
5800
+ /* \u2500\u2500 Section \u2500\u2500 */
5801
+ .section { display: none; flex-direction: column; gap: 16px; }
5802
+ .section.active { display: flex; }
5723
5803
 
5724
- .category-header {
5804
+ .section-header {
5725
5805
  display: flex;
5726
- justify-content: space-between;
5727
5806
  align-items: center;
5728
- margin-bottom: 20px;
5729
- }
5730
-
5731
- .category-title {
5732
- font-size: 18px;
5733
- font-weight: 600;
5734
- color: #2d3748;
5735
- }
5736
-
5737
- .env-grid {
5738
- display: grid;
5739
- gap: 20px;
5807
+ gap: 12px;
5808
+ margin-bottom: 4px;
5740
5809
  }
5741
-
5742
- .env-item {
5743
- border: 1px solid #e2e8f0;
5810
+ .section-icon {
5811
+ width: 36px; height: 36px;
5812
+ background: var(--accent-lo);
5813
+ border: 1px solid var(--accent-md);
5744
5814
  border-radius: 8px;
5745
- padding: 20px;
5746
- transition: all 0.2s;
5815
+ display: grid;
5816
+ place-items: center;
5817
+ font-size: 16px;
5747
5818
  }
5819
+ .section-title { font-size: 15px; font-weight: 700; }
5820
+ .section-desc { font-family: var(--mono); font-size: 11px; color: var(--text-3); margin-top: 2px; }
5748
5821
 
5749
- .env-item:hover {
5750
- border-color: #cbd5e0;
5751
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
5822
+ /* \u2500\u2500 Env card \u2500\u2500 */
5823
+ .env-card {
5824
+ background: var(--panel);
5825
+ border: 1px solid var(--border);
5826
+ border-radius: var(--radius-lg);
5827
+ padding: 20px 24px;
5828
+ transition: border-color 0.15s;
5752
5829
  }
5830
+ .env-card:hover { border-color: var(--border-hi); }
5831
+ .env-card.has-value { border-color: rgba(249,115,22,0.15); }
5753
5832
 
5754
- .env-item-header {
5833
+ .env-card-head {
5755
5834
  display: flex;
5756
5835
  justify-content: space-between;
5757
5836
  align-items: flex-start;
5758
- margin-bottom: 10px;
5837
+ margin-bottom: 6px;
5759
5838
  }
5760
5839
 
5761
- .env-label {
5762
- font-weight: 600;
5763
- color: #2d3748;
5764
- font-size: 14px;
5840
+ .env-key {
5841
+ font-family: var(--mono);
5842
+ font-size: 13px;
5843
+ font-weight: 500;
5844
+ color: var(--text-1);
5765
5845
  display: flex;
5766
5846
  align-items: center;
5767
5847
  gap: 8px;
5768
5848
  }
5769
5849
 
5770
- .required-badge {
5771
- background: #fc8181;
5772
- color: white;
5773
- font-size: 10px;
5774
- padding: 2px 6px;
5850
+ .badge-required {
5851
+ font-family: var(--sans);
5852
+ font-size: 9px;
5853
+ font-weight: 700;
5854
+ letter-spacing: 0.08em;
5855
+ text-transform: uppercase;
5856
+ background: rgba(239,68,68,0.15);
5857
+ color: var(--red);
5858
+ border: 1px solid rgba(239,68,68,0.25);
5775
5859
  border-radius: 4px;
5860
+ padding: 2px 6px;
5861
+ }
5862
+ .badge-sensitive {
5863
+ font-size: 9px;
5776
5864
  font-weight: 700;
5865
+ font-family: var(--sans);
5866
+ letter-spacing: 0.08em;
5867
+ text-transform: uppercase;
5868
+ background: var(--amber-lo);
5869
+ color: var(--amber);
5870
+ border: 1px solid rgba(245,158,11,0.2);
5871
+ border-radius: 4px;
5872
+ padding: 2px 6px;
5777
5873
  }
5778
5874
 
5779
- .env-description {
5780
- font-size: 13px;
5781
- color: #718096;
5782
- margin-bottom: 10px;
5875
+ .env-desc {
5876
+ font-family: var(--mono);
5877
+ font-size: 11px;
5878
+ color: var(--text-3);
5879
+ margin-bottom: 14px;
5880
+ line-height: 1.5;
5783
5881
  }
5784
5882
 
5785
- .env-input-group {
5883
+ .env-input-row {
5786
5884
  display: flex;
5787
- gap: 10px;
5885
+ gap: 8px;
5788
5886
  align-items: center;
5789
5887
  }
5790
5888
 
5791
- .env-input {
5889
+ .env-input, .env-select {
5792
5890
  flex: 1;
5793
- padding: 10px 12px;
5794
- border: 1px solid #e2e8f0;
5795
- border-radius: 6px;
5796
- font-size: 14px;
5797
- font-family: 'Monaco', 'Courier New', monospace;
5798
- transition: all 0.2s;
5799
- }
5800
-
5801
- .env-input:focus {
5891
+ background: var(--surface);
5892
+ border: 1px solid var(--border-hi);
5893
+ border-radius: 8px;
5894
+ padding: 10px 14px;
5895
+ font-family: var(--mono);
5896
+ font-size: 13px;
5897
+ color: var(--text-1);
5802
5898
  outline: none;
5803
- border-color: #667eea;
5804
- box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
5899
+ transition: border-color 0.15s, box-shadow 0.15s;
5900
+ appearance: none;
5805
5901
  }
5806
-
5807
- .env-input.error {
5808
- border-color: #fc8181;
5902
+ .env-input:focus, .env-select:focus {
5903
+ border-color: var(--accent);
5904
+ box-shadow: 0 0 0 3px rgba(249,115,22,0.12);
5809
5905
  }
5906
+ .env-input.changed { border-color: rgba(249,115,22,0.5); }
5907
+ .env-input.invalid { border-color: var(--red); }
5810
5908
 
5811
- .env-actions {
5812
- display: flex;
5813
- gap: 5px;
5814
- }
5909
+ .env-select option { background: var(--panel); }
5815
5910
 
5816
- .icon-btn {
5817
- padding: 8px;
5818
- border: none;
5819
- background: #e2e8f0;
5820
- border-radius: 6px;
5911
+ .env-action-btn {
5912
+ width: 36px; height: 36px;
5913
+ border-radius: 8px;
5914
+ border: 1px solid var(--border-hi);
5915
+ background: var(--surface);
5916
+ color: var(--text-2);
5821
5917
  cursor: pointer;
5822
- transition: all 0.2s;
5823
- font-size: 16px;
5824
- }
5825
-
5826
- .icon-btn:hover {
5827
- background: #cbd5e0;
5828
- }
5829
-
5830
- .icon-btn.test {
5831
- background: #bee3f8;
5832
- color: #2c5282;
5833
- }
5834
-
5835
- .icon-btn.test:hover {
5836
- background: #90cdf4;
5837
- }
5838
-
5839
- .icon-btn.generate {
5840
- background: #c6f6d5;
5841
- color: #22543d;
5842
- }
5843
-
5844
- .icon-btn.generate:hover {
5845
- background: #9ae6b4;
5918
+ display: grid;
5919
+ place-items: center;
5920
+ font-size: 14px;
5921
+ transition: all 0.15s;
5922
+ flex-shrink: 0;
5846
5923
  }
5924
+ .env-action-btn:hover { background: var(--panel); color: var(--text-1); border-color: var(--border-hi); }
5925
+ .env-action-btn.test-btn:hover { background: var(--blue-lo); color: var(--blue); border-color: rgba(56,189,248,0.25); }
5926
+ .env-action-btn.gen-btn:hover { background: var(--green-lo); color: var(--green); border-color: rgba(34,197,94,0.25); }
5847
5927
 
5848
- .error-message {
5849
- color: #e53e3e;
5850
- font-size: 12px;
5851
- margin-top: 5px;
5928
+ .env-feedback {
5929
+ font-family: var(--mono);
5930
+ font-size: 11px;
5931
+ margin-top: 8px;
5932
+ min-height: 16px;
5852
5933
  }
5934
+ .env-feedback.error { color: var(--red); }
5935
+ .env-feedback.success { color: var(--green); }
5853
5936
 
5854
- .success-message {
5855
- color: #38a169;
5856
- font-size: 12px;
5857
- margin-top: 5px;
5937
+ /* \u2500\u2500 Toast \u2500\u2500 */
5938
+ .toast-zone {
5939
+ position: fixed;
5940
+ bottom: 24px;
5941
+ right: 24px;
5942
+ display: flex;
5943
+ flex-direction: column;
5944
+ gap: 8px;
5945
+ z-index: 100;
5858
5946
  }
5859
5947
 
5860
- .alert {
5861
- padding: 15px 20px;
5862
- border-radius: 8px;
5863
- margin-bottom: 20px;
5948
+ .toast {
5949
+ padding: 12px 18px;
5950
+ border-radius: 10px;
5951
+ font-size: 13px;
5952
+ font-weight: 600;
5864
5953
  display: flex;
5865
5954
  align-items: center;
5866
5955
  gap: 10px;
5956
+ animation: slideIn 0.2s ease;
5957
+ max-width: 380px;
5867
5958
  }
5959
+ .toast.success { background: var(--panel); border: 1px solid rgba(34,197,94,0.3); color: var(--green); }
5960
+ .toast.error { background: var(--panel); border: 1px solid rgba(239,68,68,0.3); color: var(--red); }
5961
+ .toast.warning { background: var(--panel); border: 1px solid rgba(245,158,11,0.3); color: var(--amber); }
5962
+ .toast.info { background: var(--panel); border: 1px solid rgba(56,189,248,0.3); color: var(--blue); }
5868
5963
 
5869
- .alert-warning {
5870
- background: #fef5e7;
5871
- border-left: 4px solid #f59e0b;
5872
- color: #92400e;
5873
- }
5874
-
5875
- .alert-info {
5876
- background: #eff6ff;
5877
- border-left: 4px solid #3b82f6;
5878
- color: #1e40af;
5964
+ @keyframes slideIn {
5965
+ from { opacity: 0; transform: translateY(8px); }
5966
+ to { opacity: 1; transform: translateY(0); }
5879
5967
  }
5880
5968
 
5881
- .alert-success {
5882
- background: #f0fdf4;
5883
- border-left: 4px solid #10b981;
5884
- color: #065f46;
5969
+ /* \u2500\u2500 Modal (test result) \u2500\u2500 */
5970
+ .modal-backdrop {
5971
+ display: none;
5972
+ position: fixed; inset: 0;
5973
+ background: rgba(0,0,0,0.6);
5974
+ z-index: 200;
5975
+ align-items: center;
5976
+ justify-content: center;
5885
5977
  }
5978
+ .modal-backdrop.open { display: flex; }
5886
5979
 
5887
- .loading {
5888
- text-align: center;
5889
- padding: 40px;
5890
- color: #718096;
5980
+ .modal {
5981
+ background: var(--surface);
5982
+ border: 1px solid var(--border-hi);
5983
+ border-radius: var(--radius-lg);
5984
+ padding: 28px;
5985
+ width: 420px;
5986
+ max-width: 90vw;
5987
+ box-shadow: 0 24px 64px rgba(0,0,0,0.5);
5988
+ }
5989
+ .modal-title { font-size: 15px; font-weight: 700; margin-bottom: 16px; }
5990
+ .modal-body { margin-bottom: 20px; }
5991
+ .modal-result {
5992
+ padding: 14px;
5993
+ border-radius: 8px;
5994
+ font-family: var(--mono);
5995
+ font-size: 12px;
5891
5996
  }
5997
+ .modal-result.ok { background: var(--green-lo); border: 1px solid rgba(34,197,94,0.2); color: var(--green); }
5998
+ .modal-result.fail { background: var(--red-lo); border: 1px solid rgba(239,68,68,0.2); color: var(--red); }
5999
+ .modal-footer { display: flex; justify-content: flex-end; }
5892
6000
 
6001
+ /* \u2500\u2500 Spinner \u2500\u2500 */
5893
6002
  .spinner {
5894
- border: 3px solid #e2e8f0;
5895
- border-top: 3px solid #667eea;
6003
+ width: 36px; height: 36px;
6004
+ border: 3px solid var(--border);
6005
+ border-top-color: var(--accent);
5896
6006
  border-radius: 50%;
5897
- width: 40px;
5898
- height: 40px;
5899
- animation: spin 1s linear infinite;
5900
- margin: 0 auto 20px;
6007
+ animation: spin 0.8s linear infinite;
6008
+ margin: 0 auto 16px;
5901
6009
  }
6010
+ @keyframes spin { to { transform: rotate(360deg); } }
5902
6011
 
5903
- @keyframes spin {
5904
- 0% { transform: rotate(0deg); }
5905
- 100% { transform: rotate(360deg); }
6012
+ .loading-state {
6013
+ text-align: center;
6014
+ padding: 60px 0;
6015
+ color: var(--text-3);
6016
+ font-family: var(--mono);
6017
+ font-size: 12px;
5906
6018
  }
5907
6019
 
5908
- .modal {
6020
+ /* \u2500\u2500 Restart banner \u2500\u2500 */
6021
+ .restart-banner {
5909
6022
  display: none;
5910
- position: fixed;
5911
- top: 0;
5912
- left: 0;
5913
- right: 0;
5914
- bottom: 0;
5915
- background: rgba(0, 0, 0, 0.5);
5916
- z-index: 1000;
6023
+ background: var(--amber-lo);
6024
+ border: 1px solid rgba(245,158,11,0.25);
6025
+ border-radius: 10px;
6026
+ padding: 12px 18px;
6027
+ font-size: 13px;
6028
+ color: var(--amber);
6029
+ font-weight: 600;
5917
6030
  align-items: center;
5918
- justify-content: center;
6031
+ gap: 10px;
5919
6032
  }
6033
+ .restart-banner.show { display: flex; }
6034
+ </style>
6035
+ </head>
6036
+ <body>
6037
+ <div class="shell">
5920
6038
 
5921
- .modal.show {
5922
- display: flex;
5923
- }
6039
+ <!-- Sidebar -->
6040
+ <aside>
6041
+ <div class="logo">
6042
+ <div class="logo-mark">Q</div>
6043
+ <div>
6044
+ <div class="logo-name">OpenQA</div>
6045
+ <div class="logo-version">v1.3.4</div>
6046
+ </div>
6047
+ </div>
5924
6048
 
5925
- .modal-content {
5926
- background: white;
5927
- padding: 30px;
5928
- border-radius: 12px;
5929
- max-width: 500px;
5930
- width: 90%;
5931
- box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
5932
- }
6049
+ <div class="nav-section">
6050
+ <div class="nav-label">Overview</div>
6051
+ <a class="nav-item" href="/">
6052
+ <span class="icon">\u{1F4CA}</span> Dashboard
6053
+ </a>
6054
+ <a class="nav-item" href="/sessions">
6055
+ <span class="icon">\u{1F9EA}</span> Sessions
6056
+ </a>
6057
+ <a class="nav-item" href="/issues">
6058
+ <span class="icon">\u{1F41B}</span> Issues
6059
+ </a>
5933
6060
 
5934
- .modal-header {
5935
- font-size: 20px;
5936
- font-weight: 600;
5937
- margin-bottom: 15px;
5938
- color: #2d3748;
5939
- }
6061
+ <div class="nav-label">Testing</div>
6062
+ <a class="nav-item" href="/tests">
6063
+ <span class="icon">\u26A1</span> Tests
6064
+ </a>
6065
+ <a class="nav-item" href="/coverage">
6066
+ <span class="icon">\u{1F4C8}</span> Coverage
6067
+ </a>
6068
+ <a class="nav-item" href="/kanban">
6069
+ <span class="icon">\u{1F4CB}</span> Kanban
6070
+ </a>
5940
6071
 
5941
- .modal-body {
5942
- margin-bottom: 20px;
5943
- color: #4a5568;
5944
- }
6072
+ <div class="nav-label">System</div>
6073
+ <a class="nav-item" href="/config">
6074
+ <span class="icon">\u2699\uFE0F</span> Config
6075
+ </a>
6076
+ <a class="nav-item active" href="/config/env">
6077
+ <span class="icon">\u{1F527}</span> Environment
6078
+ </a>
6079
+ <a class="nav-item" href="/logs">
6080
+ <span class="icon">\u{1F4DC}</span> Logs
6081
+ </a>
6082
+ </div>
5945
6083
 
5946
- .modal-footer {
5947
- display: flex;
5948
- gap: 10px;
5949
- justify-content: flex-end;
5950
- }
5951
- </style>
5952
- </head>
5953
- <body>
5954
- <div class="container">
5955
- <div class="header">
5956
- <h1>
5957
- <span>\u2699\uFE0F</span>
6084
+ <div class="sidebar-footer">
6085
+ <div style="font-family:var(--mono);font-size:11px;color:var(--text-3);">
5958
6086
  Environment Variables
5959
- </h1>
5960
- <div class="header-actions">
5961
- <a href="/config" class="btn btn-secondary">\u2190 Back to Config</a>
5962
- <button id="saveBtn" class="btn btn-success" disabled>\u{1F4BE} Save Changes</button>
6087
+ </div>
6088
+ </div>
6089
+ </aside>
6090
+
6091
+ <!-- Main -->
6092
+ <main>
6093
+ <div class="topbar">
6094
+ <div>
6095
+ <div class="page-title">Environment Variables</div>
6096
+ <div class="page-sub">Configure runtime variables for OpenQA</div>
6097
+ </div>
6098
+ <div class="topbar-actions">
6099
+ <a class="btn btn-ghost" href="/config">\u2190 Back to Config</a>
6100
+ <button id="saveBtn" class="btn btn-primary" disabled>
6101
+ \u{1F4BE} Save Changes
6102
+ </button>
5963
6103
  </div>
5964
6104
  </div>
5965
6105
 
5966
6106
  <div class="content">
5967
- <div id="loading" class="loading">
6107
+
6108
+ <!-- Restart banner -->
6109
+ <div class="restart-banner" id="restartBanner">
6110
+ \u26A0\uFE0F Some changes require a server restart to take effect.
6111
+ </div>
6112
+
6113
+ <!-- Loading -->
6114
+ <div class="loading-state" id="loadingState">
5968
6115
  <div class="spinner"></div>
5969
- <div>Loading environment variables...</div>
6116
+ Loading environment variables\u2026
5970
6117
  </div>
5971
6118
 
5972
- <div id="main" style="display: none;">
5973
- <div id="alerts"></div>
5974
-
5975
- <div class="tabs">
5976
- <button class="tab active" data-category="llm">\u{1F916} LLM</button>
5977
- <button class="tab" data-category="security">\u{1F512} Security</button>
5978
- <button class="tab" data-category="target">\u{1F3AF} Target App</button>
5979
- <button class="tab" data-category="github">\u{1F419} GitHub</button>
5980
- <button class="tab" data-category="web">\u{1F310} Web Server</button>
5981
- <button class="tab" data-category="agent">\u{1F916} Agent</button>
5982
- <button class="tab" data-category="database">\u{1F4BE} Database</button>
5983
- <button class="tab" data-category="notifications">\u{1F514} Notifications</button>
5984
- </div>
6119
+ <!-- Main content (hidden while loading) -->
6120
+ <div id="mainContent" style="display:none;flex-direction:column;gap:24px;">
6121
+
6122
+ <!-- Tab bar -->
6123
+ <div class="tab-bar" id="tabBar"></div>
6124
+
6125
+ <!-- Sections -->
6126
+ <div id="sections"></div>
5985
6127
 
5986
- <div id="categories"></div>
5987
6128
  </div>
5988
6129
  </div>
5989
- </div>
6130
+ </main>
6131
+ </div>
5990
6132
 
5991
- <!-- Test Result Modal -->
5992
- <div id="testModal" class="modal">
5993
- <div class="modal-content">
5994
- <div class="modal-header">Test Result</div>
5995
- <div class="modal-body" id="testResult"></div>
5996
- <div class="modal-footer">
5997
- <button class="btn btn-secondary" onclick="closeTestModal()">Close</button>
5998
- </div>
6133
+ <!-- Test result modal -->
6134
+ <div class="modal-backdrop" id="testModal">
6135
+ <div class="modal">
6136
+ <div class="modal-title">Connection Test</div>
6137
+ <div class="modal-body">
6138
+ <div class="modal-result" id="testResultBox">\u2026</div>
6139
+ </div>
6140
+ <div class="modal-footer">
6141
+ <button class="btn btn-ghost" onclick="closeModal()">Close</button>
5999
6142
  </div>
6000
6143
  </div>
6144
+ </div>
6001
6145
 
6002
- <script>
6003
- let envVariables = [];
6004
- let changedVariables = {};
6005
- let restartRequired = false;
6146
+ <!-- Toast zone -->
6147
+ <div class="toast-zone" id="toastZone"></div>
6006
6148
 
6007
- // Load environment variables
6008
- async function loadEnvVariables() {
6009
- try {
6010
- const response = await fetch('/api/env');
6011
- if (!response.ok) throw new Error('Failed to load variables');
6012
-
6013
- const data = await response.json();
6014
- envVariables = data.variables;
6015
-
6016
- renderCategories();
6017
- document.getElementById('loading').style.display = 'none';
6018
- document.getElementById('main').style.display = 'block';
6019
- } catch (error) {
6020
- showAlert('error', 'Failed to load environment variables: ' + error.message);
6021
- }
6022
- }
6149
+ <script>
6150
+ /* \u2500\u2500 State \u2500\u2500 */
6151
+ let envVars = [];
6152
+ let changed = {};
6153
+ let hasRequiredMissing = false;
6154
+
6155
+ const TABS = [
6156
+ { id: 'llm', label: '\u{1F916} LLM', desc: 'Language model provider & API keys' },
6157
+ { id: 'security', label: '\u{1F512} Security', desc: 'Authentication & JWT configuration' },
6158
+ { id: 'target', label: '\u{1F3AF} Target App', desc: 'Application under test settings' },
6159
+ { id: 'github', label: '\u{1F419} GitHub', desc: 'Repository & CI/CD integration' },
6160
+ { id: 'web', label: '\u{1F310} Web Server', desc: 'HTTP host, port & CORS settings' },
6161
+ { id: 'agent', label: '\u{1F916} Agent', desc: 'Autonomous agent behaviour' },
6162
+ { id: 'database', label: '\u{1F4BE} Database', desc: 'Persistence & storage' },
6163
+ { id: 'notifications', label: '\u{1F514} Notifications', desc: 'Slack & Discord webhooks' },
6164
+ ];
6023
6165
 
6024
- // Render categories
6025
- function renderCategories() {
6026
- const container = document.getElementById('categories');
6027
- const categories = [...new Set(envVariables.map(v => v.category))];
6028
-
6029
- categories.forEach((category, index) => {
6030
- const section = document.createElement('div');
6031
- section.className = 'category-section' + (index === 0 ? ' active' : '');
6032
- section.dataset.category = category;
6033
-
6034
- const vars = envVariables.filter(v => v.category === category);
6035
-
6036
- section.innerHTML = \`
6037
- <div class="category-header">
6038
- <div class="category-title">\${getCategoryTitle(category)}</div>
6039
- </div>
6040
- <div class="env-grid">
6041
- \${vars.map(v => renderEnvItem(v)).join('')}
6042
- </div>
6043
- \`;
6044
-
6045
- container.appendChild(section);
6046
- });
6047
- }
6166
+ /* \u2500\u2500 Init \u2500\u2500 */
6167
+ async function init() {
6168
+ try {
6169
+ const res = await fetch('/api/env');
6170
+ if (!res.ok) { toast('error', 'Failed to load environment variables (status ' + res.status + ')'); return; }
6171
+ const data = await res.json();
6172
+ envVars = data.variables || [];
6173
+ renderAll();
6174
+ document.getElementById('loadingState').style.display = 'none';
6175
+ const mc = document.getElementById('mainContent');
6176
+ mc.style.display = 'flex';
6177
+ } catch (e) {
6178
+ toast('error', 'Network error \u2014 ' + e.message);
6179
+ }
6180
+ }
6048
6181
 
6049
- // Render single env item
6050
- function renderEnvItem(envVar) {
6051
- const inputType = envVar.type === 'password' ? 'password' : 'text';
6052
- const value = envVar.displayValue || '';
6053
-
6054
- return \`
6055
- <div class="env-item" data-key="\${envVar.key}">
6056
- <div class="env-item-header">
6057
- <div class="env-label">
6058
- \${envVar.key}
6059
- \${envVar.required ? '<span class="required-badge">REQUIRED</span>' : ''}
6060
- </div>
6061
- </div>
6062
- <div class="env-description">\${envVar.description}</div>
6063
- <div class="env-input-group">
6064
- \${envVar.type === 'select' ?
6065
- \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
6066
- <option value="">-- Select --</option>
6067
- \${envVar.options.map(opt =>
6068
- \`<option value="\${opt}" \${value === opt ? 'selected' : ''}>\${opt}</option>\`
6069
- ).join('')}
6070
- </select>\` :
6071
- envVar.type === 'boolean' ?
6072
- \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
6073
- <option value="">-- Select --</option>
6074
- <option value="true" \${value === 'true' ? 'selected' : ''}>true</option>
6075
- <option value="false" \${value === 'false' ? 'selected' : ''}>false</option>
6076
- </select>\` :
6077
- \`<input
6078
- type="\${inputType}"
6079
- class="env-input"
6080
- data-key="\${envVar.key}"
6081
- value="\${value}"
6082
- placeholder="\${envVar.placeholder || ''}"
6083
- onchange="handleChange(this)"
6084
- />\`
6085
- }
6086
- <div class="env-actions">
6087
- \${envVar.testable ? \`<button class="icon-btn test" onclick="testVariable('\${envVar.key}')" title="Test">\u{1F9EA}</button>\` : ''}
6088
- \${envVar.key === 'OPENQA_JWT_SECRET' ? \`<button class="icon-btn generate" onclick="generateSecret('\${envVar.key}')" title="Generate">\u{1F511}</button>\` : ''}
6089
- </div>
6090
- </div>
6091
- <div class="error-message" id="error-\${envVar.key}"></div>
6092
- <div class="success-message" id="success-\${envVar.key}"></div>
6182
+ /* \u2500\u2500 Render \u2500\u2500 */
6183
+ function renderAll() {
6184
+ renderTabBar();
6185
+ renderSections();
6186
+ activateTab(TABS[0].id);
6187
+ }
6188
+
6189
+ function renderTabBar() {
6190
+ const bar = document.getElementById('tabBar');
6191
+ bar.innerHTML = TABS.map(t => {
6192
+ const vars = envVars.filter(v => v.category === t.id);
6193
+ const hasRequired = vars.some(v => v.required);
6194
+ return \`<button class="tab-btn\${hasRequired ? ' has-required' : ''}" data-tab="\${t.id}" onclick="activateTab('\${t.id}')">
6195
+ <span class="tab-dot"></span>
6196
+ \${t.label}
6197
+ </button>\`;
6198
+ }).join('');
6199
+ }
6200
+
6201
+ function renderSections() {
6202
+ const container = document.getElementById('sections');
6203
+ container.innerHTML = TABS.map(t => {
6204
+ const vars = envVars.filter(v => v.category === t.id);
6205
+ return \`<div class="section" id="section-\${t.id}">
6206
+ <div class="section-header">
6207
+ <div class="section-icon">\${t.label.split(' ')[0]}</div>
6208
+ <div>
6209
+ <div class="section-title">\${t.label.slice(t.label.indexOf(' ')+1)}</div>
6210
+ <div class="section-desc">\${t.desc}</div>
6093
6211
  </div>
6094
- \`;
6095
- }
6212
+ </div>
6213
+ \${vars.map(renderCard).join('')}
6214
+ \${vars.length === 0 ? '<div style="color:var(--text-3);font-family:var(--mono);font-size:12px;padding:20px 0">No variables in this category.</div>' : ''}
6215
+ </div>\`;
6216
+ }).join('');
6217
+ }
6096
6218
 
6097
- // Handle input change
6098
- function handleChange(input) {
6099
- const key = input.dataset.key;
6100
- const value = input.value;
6101
-
6102
- changedVariables[key] = value;
6103
- document.getElementById('saveBtn').disabled = false;
6104
-
6105
- // Clear messages
6106
- document.getElementById(\`error-\${key}\`).textContent = '';
6107
- document.getElementById(\`success-\${key}\`).textContent = '';
6108
- }
6219
+ function renderCard(v) {
6220
+ const displayVal = v.displayValue || '';
6221
+ const isSensitive = v.sensitive;
6222
+ const inputType = (v.type === 'password' && !changed[v.key]) ? 'password' : 'text';
6223
+
6224
+ let inputHTML = '';
6225
+ if (v.type === 'select' || v.type === 'boolean') {
6226
+ const opts = v.type === 'boolean'
6227
+ ? [{ val: 'true', lbl: 'true' }, { val: 'false', lbl: 'false' }]
6228
+ : (v.options || []).map(o => ({ val: o, lbl: o }));
6229
+ inputHTML = \`<select class="env-select" data-key="\${v.key}" onchange="handleChange(this)">
6230
+ <option value="">\u2014 Select \u2014</option>
6231
+ \${opts.map(o => \`<option value="\${o.val}" \${displayVal === o.val ? 'selected' : ''}>\${o.lbl}</option>\`).join('')}
6232
+ </select>\`;
6233
+ } else {
6234
+ inputHTML = \`<input
6235
+ type="\${inputType}"
6236
+ class="env-input"
6237
+ data-key="\${v.key}"
6238
+ value="\${escHtml(displayVal)}"
6239
+ placeholder="\${escHtml(v.placeholder || '')}"
6240
+ oninput="handleChange(this)"
6241
+ autocomplete="off"
6242
+ />\`;
6243
+ }
6109
6244
 
6110
- // Save changes
6111
- async function saveChanges() {
6112
- const saveBtn = document.getElementById('saveBtn');
6113
- saveBtn.disabled = true;
6114
- saveBtn.textContent = '\u{1F4BE} Saving...';
6115
-
6116
- try {
6117
- const response = await fetch('/api/env/bulk', {
6118
- method: 'POST',
6119
- headers: { 'Content-Type': 'application/json' },
6120
- body: JSON.stringify({ variables: changedVariables }),
6121
- });
6122
-
6123
- if (!response.ok) {
6124
- const error = await response.json();
6125
- throw new Error(error.error || 'Failed to save');
6245
+ const testBtn = v.testable
6246
+ ? \`<button class="env-action-btn test-btn" onclick="testVar('\${v.key}')" title="Test connection">\u{1F9EA}</button>\`
6247
+ : '';
6248
+
6249
+ const genBtn = v.key === 'OPENQA_JWT_SECRET'
6250
+ ? \`<button class="env-action-btn gen-btn" onclick="generateSecret('\${v.key}')" title="Generate secret">\u{1F511}</button>\`
6251
+ : '';
6252
+
6253
+ const toggleBtn = (v.type === 'password' || isSensitive)
6254
+ ? \`<button class="env-action-btn" onclick="toggleVis('\${v.key}')" title="Toggle visibility" id="vis-\${v.key}">\u{1F441}</button>\`
6255
+ : '';
6256
+
6257
+ return \`<div class="env-card\${displayVal ? ' has-value' : ''}" id="card-\${v.key}">
6258
+ <div class="env-card-head">
6259
+ <div class="env-key">
6260
+ \${v.key}
6261
+ \${v.required ? '<span class="badge-required">Required</span>' : ''}
6262
+ \${isSensitive ? '<span class="badge-sensitive">Sensitive</span>' : ''}
6263
+ </div>
6264
+ </div>
6265
+ <div class="env-desc">\${v.description}</div>
6266
+ <div class="env-input-row">
6267
+ \${inputHTML}
6268
+ \${toggleBtn}
6269
+ \${testBtn}
6270
+ \${genBtn}
6271
+ </div>
6272
+ <div class="env-feedback" id="fb-\${v.key}"></div>
6273
+ </div>\`;
6274
+ }
6275
+
6276
+ /* \u2500\u2500 Tab switching \u2500\u2500 */
6277
+ function activateTab(id) {
6278
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === id));
6279
+ document.querySelectorAll('.section').forEach(s => s.classList.toggle('active', s.id === 'section-' + id));
6280
+ }
6281
+
6282
+ /* \u2500\u2500 Input handling \u2500\u2500 */
6283
+ function handleChange(el) {
6284
+ const key = el.dataset.key;
6285
+ const val = el.value;
6286
+ changed[key] = val;
6287
+ el.classList.add('changed');
6288
+ el.classList.remove('invalid');
6289
+ clearFeedback(key);
6290
+ document.getElementById('saveBtn').disabled = false;
6291
+ }
6292
+
6293
+ /* \u2500\u2500 Toggle password visibility \u2500\u2500 */
6294
+ function toggleVis(key) {
6295
+ const inp = document.querySelector('[data-key="' + key + '"]');
6296
+ if (!inp || inp.tagName !== 'INPUT') return;
6297
+ inp.type = inp.type === 'password' ? 'text' : 'password';
6298
+ }
6299
+
6300
+ /* \u2500\u2500 Save \u2500\u2500 */
6301
+ async function saveChanges() {
6302
+ if (!Object.keys(changed).length) return;
6303
+
6304
+ const btn = document.getElementById('saveBtn');
6305
+ btn.disabled = true;
6306
+ btn.textContent = '\u23F3 Saving\u2026';
6307
+
6308
+ try {
6309
+ const res = await fetch('/api/env/bulk', {
6310
+ method: 'POST',
6311
+ headers: { 'Content-Type': 'application/json' },
6312
+ body: JSON.stringify({ variables: changed }),
6313
+ credentials: 'include',
6314
+ });
6315
+
6316
+ const body = await res.json().catch(() => ({}));
6317
+
6318
+ if (!res.ok) {
6319
+ const errStr = body.errors
6320
+ ? Object.entries(body.errors).map(([k, v]) => k + ': ' + v).join('; ')
6321
+ : body.error || 'Failed to save';
6322
+ // Show per-field errors
6323
+ if (body.errors) {
6324
+ for (const [k, msg] of Object.entries(body.errors)) {
6325
+ setFeedback(k, 'error', msg);
6326
+ const inp = document.querySelector('[data-key="' + k + '"]');
6327
+ if (inp) inp.classList.add('invalid');
6126
6328
  }
6127
-
6128
- const result = await response.json();
6129
- restartRequired = result.restartRequired;
6130
-
6131
- showAlert('success', \`\u2705 Saved \${result.updated} variable(s) successfully!\` +
6132
- (restartRequired ? ' \u26A0\uFE0F Restart required for changes to take effect.' : ''));
6133
-
6134
- changedVariables = {};
6135
- saveBtn.textContent = '\u{1F4BE} Save Changes';
6136
-
6137
- // Reload to show updated values
6138
- setTimeout(() => location.reload(), 2000);
6139
- } catch (error) {
6140
- showAlert('error', 'Failed to save: ' + error.message);
6141
- saveBtn.disabled = false;
6142
- saveBtn.textContent = '\u{1F4BE} Save Changes';
6143
6329
  }
6330
+ toast('error', errStr);
6331
+ btn.disabled = false;
6332
+ btn.innerHTML = '\u{1F4BE} Save Changes';
6333
+ return;
6144
6334
  }
6145
6335
 
6146
- // Test variable
6147
- async function testVariable(key) {
6148
- const input = document.querySelector(\`[data-key="\${key}"]\`);
6149
- const value = input.value;
6150
-
6151
- if (!value) {
6152
- showAlert('warning', 'Please enter a value first');
6153
- return;
6154
- }
6155
-
6156
- try {
6157
- const response = await fetch(\`/api/env/test/\${key}\`, {
6158
- method: 'POST',
6159
- headers: { 'Content-Type': 'application/json' },
6160
- body: JSON.stringify({ value }),
6161
- });
6162
-
6163
- const result = await response.json();
6164
- showTestResult(result);
6165
- } catch (error) {
6166
- showTestResult({ success: false, message: 'Test failed: ' + error.message });
6167
- }
6336
+ toast('success', '\u2705 Saved ' + body.updated + ' variable(s)');
6337
+ if (body.restartRequired) {
6338
+ document.getElementById('restartBanner').classList.add('show');
6168
6339
  }
6169
6340
 
6170
- // Generate secret
6171
- async function generateSecret(key) {
6172
- try {
6173
- const response = await fetch(\`/api/env/generate/\${key}\`, {
6174
- method: 'POST',
6175
- });
6176
-
6177
- if (!response.ok) throw new Error('Failed to generate');
6178
-
6179
- const result = await response.json();
6180
- const input = document.querySelector(\`[data-key="\${key}"]\`);
6181
- input.value = result.value;
6182
- handleChange(input);
6183
-
6184
- document.getElementById(\`success-\${key}\`).textContent = '\u2705 Secret generated!';
6185
- } catch (error) {
6186
- document.getElementById(\`error-\${key}\`).textContent = 'Failed to generate: ' + error.message;
6187
- }
6188
- }
6341
+ changed = {};
6342
+ btn.innerHTML = '\u{1F4BE} Save Changes';
6343
+ // Reload to reflect masked values
6344
+ setTimeout(() => location.reload(), 1200);
6345
+ } catch (e) {
6346
+ toast('error', 'Network error \u2014 ' + e.message);
6347
+ btn.disabled = false;
6348
+ btn.innerHTML = '\u{1F4BE} Save Changes';
6349
+ }
6350
+ }
6189
6351
 
6190
- // Show test result
6191
- function showTestResult(result) {
6192
- const modal = document.getElementById('testModal');
6193
- const resultDiv = document.getElementById('testResult');
6194
-
6195
- resultDiv.innerHTML = \`
6196
- <div class="alert \${result.success ? 'alert-success' : 'alert-warning'}">
6197
- \${result.success ? '\u2705' : '\u274C'} \${result.message}
6198
- </div>
6199
- \`;
6200
-
6201
- modal.classList.add('show');
6202
- }
6352
+ /* \u2500\u2500 Test variable \u2500\u2500 */
6353
+ async function testVar(key) {
6354
+ const inp = document.querySelector('[data-key="' + key + '"]');
6355
+ const val = inp ? inp.value : '';
6356
+ if (!val) { toast('warning', 'Enter a value first'); return; }
6203
6357
 
6204
- function closeTestModal() {
6205
- document.getElementById('testModal').classList.remove('show');
6206
- }
6358
+ setFeedback(key, '', '');
6359
+ const btn = document.querySelector('[onclick="testVar(\\''+key+'\\')"]');
6360
+ if (btn) { btn.textContent = '\u23F3'; btn.disabled = true; }
6207
6361
 
6208
- // Show alert
6209
- function showAlert(type, message) {
6210
- const alerts = document.getElementById('alerts');
6211
- const alertClass = type === 'error' ? 'alert-warning' :
6212
- type === 'success' ? 'alert-success' : 'alert-info';
6213
-
6214
- alerts.innerHTML = \`
6215
- <div class="alert \${alertClass}">
6216
- \${message}
6217
- </div>
6218
- \`;
6219
-
6220
- setTimeout(() => alerts.innerHTML = '', 5000);
6221
- }
6222
-
6223
- // Get category title
6224
- function getCategoryTitle(category) {
6225
- const titles = {
6226
- llm: '\u{1F916} LLM Configuration',
6227
- security: '\u{1F512} Security Settings',
6228
- target: '\u{1F3AF} Target Application',
6229
- github: '\u{1F419} GitHub Integration',
6230
- web: '\u{1F310} Web Server',
6231
- agent: '\u{1F916} Agent Configuration',
6232
- database: '\u{1F4BE} Database',
6233
- notifications: '\u{1F514} Notifications',
6234
- };
6235
- return titles[category] || category;
6236
- }
6237
-
6238
- // Tab switching
6239
- document.addEventListener('click', (e) => {
6240
- if (e.target.classList.contains('tab')) {
6241
- const category = e.target.dataset.category;
6242
-
6243
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
6244
- e.target.classList.add('active');
6245
-
6246
- document.querySelectorAll('.category-section').forEach(s => s.classList.remove('active'));
6247
- document.querySelector(\`[data-category="\${category}"]\`).classList.add('active');
6248
- }
6362
+ try {
6363
+ const res = await fetch('/api/env/test/' + key, {
6364
+ method: 'POST',
6365
+ headers: { 'Content-Type': 'application/json' },
6366
+ body: JSON.stringify({ value: val }),
6367
+ credentials: 'include',
6249
6368
  });
6369
+ const result = await res.json();
6370
+ openModal(result.success, result.message);
6371
+ setFeedback(key, result.success ? 'success' : 'error', result.success ? '\u2713 Connected' : '\u2717 ' + result.message);
6372
+ } catch (e) {
6373
+ openModal(false, 'Network error: ' + e.message);
6374
+ } finally {
6375
+ if (btn) { btn.textContent = '\u{1F9EA}'; btn.disabled = false; }
6376
+ }
6377
+ }
6250
6378
 
6251
- // Save button
6252
- document.getElementById('saveBtn').addEventListener('click', saveChanges);
6379
+ /* \u2500\u2500 Generate secret \u2500\u2500 */
6380
+ async function generateSecret(key) {
6381
+ try {
6382
+ const res = await fetch('/api/env/generate/' + key, {
6383
+ method: 'POST', credentials: 'include'
6384
+ });
6385
+ if (!res.ok) throw new Error('Failed to generate');
6386
+ const { value } = await res.json();
6387
+ const inp = document.querySelector('[data-key="' + key + '"]');
6388
+ if (inp) {
6389
+ inp.type = 'text';
6390
+ inp.value = value;
6391
+ handleChange(inp);
6392
+ }
6393
+ setFeedback(key, 'success', '\u2713 Secret generated \u2014 save to persist');
6394
+ } catch (e) {
6395
+ setFeedback(key, 'error', e.message);
6396
+ }
6397
+ }
6253
6398
 
6254
- // Load on page load
6255
- loadEnvVariables();
6256
- </script>
6399
+ /* \u2500\u2500 Modal \u2500\u2500 */
6400
+ function openModal(ok, msg) {
6401
+ const box = document.getElementById('testResultBox');
6402
+ box.className = 'modal-result ' + (ok ? 'ok' : 'fail');
6403
+ box.textContent = (ok ? '\u2713 ' : '\u2717 ') + msg;
6404
+ document.getElementById('testModal').classList.add('open');
6405
+ }
6406
+ function closeModal() {
6407
+ document.getElementById('testModal').classList.remove('open');
6408
+ }
6409
+
6410
+ /* \u2500\u2500 Toast \u2500\u2500 */
6411
+ function toast(type, msg) {
6412
+ const zone = document.getElementById('toastZone');
6413
+ const el = document.createElement('div');
6414
+ el.className = 'toast ' + type;
6415
+ el.textContent = msg;
6416
+ zone.appendChild(el);
6417
+ setTimeout(() => el.remove(), 4500);
6418
+ }
6419
+
6420
+ /* \u2500\u2500 Feedback \u2500\u2500 */
6421
+ function setFeedback(key, type, msg) {
6422
+ const el = document.getElementById('fb-' + key);
6423
+ if (!el) return;
6424
+ el.className = 'env-feedback' + (type ? ' ' + type : '');
6425
+ el.textContent = msg;
6426
+ }
6427
+ function clearFeedback(key) { setFeedback(key, '', ''); }
6428
+
6429
+ /* \u2500\u2500 Helpers \u2500\u2500 */
6430
+ function escHtml(s) {
6431
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
6432
+ }
6433
+
6434
+ /* \u2500\u2500 Wire save button \u2500\u2500 */
6435
+ document.getElementById('saveBtn').addEventListener('click', saveChanges);
6436
+
6437
+ /* \u2500\u2500 Close modal on backdrop click \u2500\u2500 */
6438
+ document.getElementById('testModal').addEventListener('click', function(e) {
6439
+ if (e.target === this) closeModal();
6440
+ });
6441
+
6442
+ /* \u2500\u2500 Boot \u2500\u2500 */
6443
+ init();
6444
+ </script>
6257
6445
  </body>
6258
6446
  </html>`;
6259
6447
  }