@openqa/cli 2.1.0 → 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.
package/dist/cli/index.js CHANGED
@@ -2668,7 +2668,7 @@ function getDashboardHTML() {
2668
2668
  .logo-mark {
2669
2669
  width: 34px;
2670
2670
  height: 34px;
2671
- background: var(--accent);
2671
+ background: transparent;
2672
2672
  border-radius: 8px;
2673
2673
  display: grid;
2674
2674
  place-items: center;
@@ -3290,7 +3290,9 @@ function getDashboardHTML() {
3290
3290
  <!-- Sidebar -->
3291
3291
  <aside>
3292
3292
  <div class="logo">
3293
- <div class="logo-mark">\u{1F52C}</div>
3293
+ <div class="logo-mark">
3294
+ <img src="https://openqa.orkajs.com/_next/image?url=https%3A%2F%2Forkajs.com%2Floutre-orka-qa.png&w=256&q=75" alt="OpenQA Logo" style="width: 40px; height: 40px;">
3295
+ </div>
3294
3296
  <div>
3295
3297
  <div class="logo-name">OpenQA</div>
3296
3298
  <div class="logo-version">v2.1.0 \xB7 OSS</div>
@@ -3300,42 +3302,69 @@ function getDashboardHTML() {
3300
3302
  <div class="nav-section">
3301
3303
  <div class="nav-label">Overview</div>
3302
3304
  <a class="nav-item active" href="/">
3303
- <span class="icon">\u25A6</span> Dashboard
3305
+ <span class="icon">
3306
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gauge-icon lucide-gauge"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
3307
+ </span> Dashboard
3304
3308
  </a>
3305
3309
  <a class="nav-item" href="/kanban">
3306
- <span class="icon">\u229E</span> Kanban
3310
+ <span class="icon">
3311
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-dashed-kanban-icon lucide-square-dashed-kanban"><path d="M8 7v7"/><path d="M12 7v4"/><path d="M16 7v9"/><path d="M5 3a2 2 0 0 0-2 2"/><path d="M9 3h1"/><path d="M14 3h1"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 9v1"/><path d="M21 14v1"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M14 21h1"/><path d="M9 21h1"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M3 14v1"/><path d="M3 9v1"/></svg>
3312
+ </span> Kanban
3307
3313
  <span class="badge" id="kanban-count">0</span>
3308
3314
  </a>
3309
3315
 
3310
3316
  <div class="nav-label">Agents</div>
3311
3317
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('agents-table')">
3312
- <span class="icon">\u25CE</span> Active Agents
3318
+ <span class="icon">
3319
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-activity-icon lucide-activity"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>
3320
+ </span> Active Agents
3313
3321
  </a>
3314
3322
  <a class="nav-item" href="javascript:void(0)" onclick="switchAgentTab('specialists'); scrollToSection('agents-table')">
3315
- <span class="icon">\u25C7</span> Specialists
3323
+ <span class="icon">
3324
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hat-glasses-icon lucide-hat-glasses"><path d="M14 18a2 2 0 0 0-4 0"/><path d="m19 11-2.11-6.657a2 2 0 0 0-2.752-1.148l-1.276.61A2 2 0 0 1 12 4H8.5a2 2 0 0 0-1.925 1.456L5 11"/><path d="M2 11h20"/><circle cx="17" cy="18" r="3"/><circle cx="7" cy="18" r="3"/></svg>
3325
+ </span> Specialists
3316
3326
  </a>
3317
3327
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('interventions-panel')">
3318
- <span class="icon">\u26A0</span> Interventions
3328
+ <span class="icon">
3329
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-cog-icon lucide-user-cog"><path d="M10 15H6a4 4 0 0 0-4 4v2"/><path d="m14.305 16.53.923-.382"/><path d="m15.228 13.852-.923-.383"/><path d="m16.852 12.228-.383-.923"/><path d="m16.852 17.772-.383.924"/><path d="m19.148 12.228.383-.923"/><path d="m19.53 18.696-.382-.924"/><path d="m20.772 13.852.924-.383"/><path d="m20.772 16.148.924.383"/><circle cx="18" cy="15" r="3"/><circle cx="9" cy="7" r="4"/></svg>
3330
+ </span> Interventions
3319
3331
  <span class="badge" id="intervention-count" style="background: var(--red);">0</span>
3320
3332
  </a>
3321
3333
 
3322
3334
  <div class="nav-label">Analysis</div>
3323
3335
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('issues-panel')">
3324
- <span class="icon">\u{1F41B}</span> Bug Reports
3336
+ <span class="icon">
3337
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bug-play-icon lucide-bug-play"><path d="M10 19.655A6 6 0 0 1 6 14v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 3.97"/><path d="M14 15.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997a1 1 0 0 1-1.517-.86z"/>
3338
+ <path d="M14.12 3.88 16 2"/>
3339
+ <path d="M21 5a4 4 0 0 1-3.55 3.97"/>
3340
+ <path d="M3 21a4 4 0 0 1 3.81-4"/>
3341
+ <path d="M3 5a4 4 0 0 0 3.55 3.97"/>
3342
+ <path d="M6 13H2"/><path d="m8 2 1.88 1.88"/>
3343
+ <path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/>
3344
+ </svg>
3345
+ </span> Bug Reports
3325
3346
  </a>
3326
3347
  <a class="nav-item" href="javascript:void(0)" onclick="switchChartTab('performance'); scrollToSection('chart-performance')">
3327
- <span class="icon">\u26A1</span> Performance
3348
+ <span class="icon">
3349
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chart-spline-icon lucide-chart-spline"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M7 16c.5-2 1.5-7 4-7 2 0 2 3 4 3 2.5 0 4.5-5 5-7"/></svg>
3350
+ </span> Performance
3328
3351
  </a>
3329
3352
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('activity-list')">
3330
- <span class="icon">\u{1F4CB}</span> Logs
3353
+ <span class="icon">
3354
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scroll-text-icon lucide-scroll-text"><path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></svg>
3355
+ </span> Logs
3331
3356
  </a>
3332
3357
 
3333
3358
  <div class="nav-label">System</div>
3334
3359
  <a class="nav-item" href="/config">
3335
- <span class="icon">\u2699</span> Config
3360
+ <span class="icon">
3361
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-columns3-cog-icon lucide-columns-3-cog"><path d="M10.5 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5.5"/><path d="m14.3 19.6 1-.4"/><path d="M15 3v7.5"/><path d="m15.2 16.9-.9-.3"/><path d="m16.6 21.7.3-.9"/><path d="m16.8 15.3-.4-1"/><path d="m19.1 15.2.3-.9"/><path d="m19.6 21.7-.4-1"/><path d="m20.7 16.8 1-.4"/><path d="m21.7 19.4-.9-.3"/><path d="M9 3v18"/><circle cx="18" cy="18" r="3"/></svg>
3362
+ </span> Config
3336
3363
  </a>
3337
3364
  <a class="nav-item" href="/config/env">
3338
- <span class="icon">\u{1F527}</span> Environment
3365
+ <span class="icon">
3366
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-columns3-cog-icon lucide-columns-3-cog"><path d="M10.5 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5.5"/><path d="m14.3 19.6 1-.4"/><path d="M15 3v7.5"/><path d="m15.2 16.9-.9-.3"/><path d="m16.6 21.7.3-.9"/><path d="m16.8 15.3-.4-1"/><path d="m19.1 15.2.3-.9"/><path d="m19.6 21.7-.4-1"/><path d="m20.7 16.8 1-.4"/><path d="m21.7 19.4-.9-.3"/><path d="M9 3v18"/><circle cx="18" cy="18" r="3"/></svg>
3367
+ </span> Environment
3339
3368
  </a>
3340
3369
  </div>
3341
3370
 
@@ -3367,7 +3396,9 @@ function getDashboardHTML() {
3367
3396
  <div class="metric-card">
3368
3397
  <div class="metric-header">
3369
3398
  <div class="metric-label">Active Agents</div>
3370
- <div class="metric-icon">\u{1F916}</div>
3399
+ <div class="metric-icon">
3400
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-terminal-icon lucide-square-terminal"><path d="m7 11 2-2-2-2"/><path d="M11 13h4"/><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/></svg>
3401
+ </div>
3371
3402
  </div>
3372
3403
  <div class="metric-value" id="active-agents">0</div>
3373
3404
  <div class="metric-change positive" id="agents-change">\u2191 0 from last hour</div>
@@ -3375,7 +3406,9 @@ function getDashboardHTML() {
3375
3406
  <div class="metric-card">
3376
3407
  <div class="metric-header">
3377
3408
  <div class="metric-label">Total Actions</div>
3378
- <div class="metric-icon">\u26A1</div>
3409
+ <div class="metric-icon">
3410
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-todo-icon lucide-list-todo"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><rect x="3" y="4" width="6" height="6" rx="1"/></svg>
3411
+ </div>
3379
3412
  </div>
3380
3413
  <div class="metric-value" id="total-actions">0</div>
3381
3414
  <div class="metric-change positive" id="actions-change">\u2191 0% this session</div>
@@ -3383,7 +3416,9 @@ function getDashboardHTML() {
3383
3416
  <div class="metric-card">
3384
3417
  <div class="metric-header">
3385
3418
  <div class="metric-label">Bugs Found</div>
3386
- <div class="metric-icon">\u{1F41B}</div>
3419
+ <div class="metric-icon">
3420
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-todo-icon lucide-list-todo"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><rect x="3" y="4" width="6" height="6" rx="1"/></svg>
3421
+ </div>
3387
3422
  </div>
3388
3423
  <div class="metric-value" id="bugs-found">0</div>
3389
3424
  <div class="metric-change negative" id="bugs-change">\u2193 0 from yesterday</div>
@@ -3391,7 +3426,9 @@ function getDashboardHTML() {
3391
3426
  <div class="metric-card">
3392
3427
  <div class="metric-header">
3393
3428
  <div class="metric-label">Success Rate</div>
3394
- <div class="metric-icon">\u2713</div>
3429
+ <div class="metric-icon">
3430
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-check-icon lucide-cloud-check"><path d="m17 15-5.5 5.5L9 18"/><path d="M5.516 16.07A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 3.501 7.327"/></svg>
3431
+ </div>
3395
3432
  </div>
3396
3433
  <div class="metric-value" id="success-rate">\u2014</div>
3397
3434
  <div class="metric-change positive" id="rate-change">\u2191 0 pts improvement</div>
@@ -5655,672 +5692,860 @@ function getEnvHTML() {
5655
5692
  <head>
5656
5693
  <meta charset="UTF-8">
5657
5694
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
5658
- <title>Environment Variables - OpenQA</title>
5695
+ <title>OpenQA \u2014 Environment</title>
5696
+ <link rel="preconnect" href="https://fonts.googleapis.com">
5697
+ <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">
5659
5698
  <style>
5660
- * { margin: 0; padding: 0; box-sizing: border-box; }
5661
-
5699
+ :root {
5700
+ --bg: #080b10;
5701
+ --surface: #0d1117;
5702
+ --panel: #111720;
5703
+ --border: rgba(255,255,255,0.06);
5704
+ --border-hi: rgba(255,255,255,0.12);
5705
+ --accent: #f97316;
5706
+ --accent-lo: rgba(249,115,22,0.08);
5707
+ --accent-md: rgba(249,115,22,0.18);
5708
+ --green: #22c55e;
5709
+ --green-lo: rgba(34,197,94,0.08);
5710
+ --red: #ef4444;
5711
+ --red-lo: rgba(239,68,68,0.08);
5712
+ --amber: #f59e0b;
5713
+ --amber-lo: rgba(245,158,11,0.08);
5714
+ --blue: #38bdf8;
5715
+ --blue-lo: rgba(56,189,248,0.08);
5716
+ --text-1: #f1f5f9;
5717
+ --text-2: #8b98a8;
5718
+ --text-3: #4b5563;
5719
+ --mono: 'DM Mono', monospace;
5720
+ --sans: 'Syne', sans-serif;
5721
+ --radius: 10px;
5722
+ --radius-lg: 16px;
5723
+ }
5724
+
5725
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
5726
+
5662
5727
  body {
5663
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
5664
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
5728
+ font-family: var(--sans);
5729
+ background: var(--bg);
5730
+ color: var(--text-1);
5665
5731
  min-height: 100vh;
5666
- padding: 20px;
5732
+ overflow-x: hidden;
5667
5733
  }
5668
5734
 
5669
- .container {
5670
- max-width: 1200px;
5671
- margin: 0 auto;
5735
+ /* \u2500\u2500 Layout \u2500\u2500 */
5736
+ .shell {
5737
+ display: grid;
5738
+ grid-template-columns: 220px 1fr;
5739
+ min-height: 100vh;
5672
5740
  }
5673
5741
 
5674
- .header {
5675
- background: rgba(255, 255, 255, 0.95);
5676
- backdrop-filter: blur(10px);
5677
- padding: 20px 30px;
5678
- border-radius: 12px;
5679
- margin-bottom: 20px;
5680
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
5742
+ /* \u2500\u2500 Sidebar \u2500\u2500 */
5743
+ aside {
5744
+ background: var(--surface);
5745
+ border-right: 1px solid var(--border);
5681
5746
  display: flex;
5682
- justify-content: space-between;
5683
- align-items: center;
5747
+ flex-direction: column;
5748
+ padding: 28px 0;
5749
+ position: sticky;
5750
+ top: 0;
5751
+ height: 100vh;
5684
5752
  }
5685
5753
 
5686
- .header h1 {
5687
- font-size: 24px;
5688
- color: #1a202c;
5754
+ .logo {
5689
5755
  display: flex;
5690
5756
  align-items: center;
5691
5757
  gap: 10px;
5758
+ padding: 0 24px 32px;
5759
+ border-bottom: 1px solid var(--border);
5760
+ margin-bottom: 12px;
5692
5761
  }
5693
5762
 
5694
- .header-actions {
5695
- display: flex;
5696
- gap: 10px;
5763
+ .logo-mark {
5764
+ width: 34px; height: 34px;
5765
+ background: var(--accent);
5766
+ border-radius: 8px;
5767
+ display: grid;
5768
+ place-items: center;
5769
+ font-size: 17px;
5770
+ font-weight: 800;
5771
+ color: #fff;
5697
5772
  }
5698
5773
 
5699
- .btn {
5700
- padding: 10px 20px;
5701
- border: none;
5702
- border-radius: 8px;
5774
+ .logo-name { font-weight: 800; font-size: 18px; letter-spacing: -0.5px; }
5775
+ .logo-version { font-family: var(--mono); font-size: 10px; color: var(--text-3); }
5776
+
5777
+ .nav-section { padding: 8px 12px; flex: 1; overflow-y: auto; }
5778
+
5779
+ .nav-label {
5780
+ font-family: var(--mono);
5781
+ font-size: 10px;
5782
+ color: var(--text-3);
5783
+ letter-spacing: 1.5px;
5784
+ text-transform: uppercase;
5785
+ padding: 0 12px;
5786
+ margin: 16px 0 6px;
5787
+ }
5788
+
5789
+ .nav-item {
5790
+ display: flex;
5791
+ align-items: center;
5792
+ gap: 10px;
5793
+ padding: 9px 12px;
5794
+ border-radius: var(--radius);
5795
+ color: var(--text-2);
5796
+ text-decoration: none;
5703
5797
  font-size: 14px;
5704
5798
  font-weight: 600;
5799
+ transition: all 0.15s ease;
5705
5800
  cursor: pointer;
5706
- transition: all 0.2s;
5707
- text-decoration: none;
5708
- display: inline-flex;
5709
- align-items: center;
5710
- gap: 8px;
5711
5801
  }
5802
+ .nav-item:hover { color: var(--text-1); background: var(--panel); }
5803
+ .nav-item.active { color: var(--accent); background: var(--accent-lo); }
5804
+ .nav-item .icon { font-size: 15px; width: 20px; text-align: center; }
5712
5805
 
5713
- .btn-primary {
5714
- background: #667eea;
5715
- color: white;
5806
+ .sidebar-footer {
5807
+ padding: 16px 24px;
5808
+ border-top: 1px solid var(--border);
5716
5809
  }
5717
5810
 
5718
- .btn-primary:hover {
5719
- background: #5568d3;
5720
- transform: translateY(-1px);
5721
- }
5811
+ /* \u2500\u2500 Main \u2500\u2500 */
5812
+ main { display: flex; flex-direction: column; min-height: 100vh; overflow-y: auto; }
5722
5813
 
5723
- .btn-secondary {
5724
- background: #e2e8f0;
5725
- color: #4a5568;
5814
+ .topbar {
5815
+ display: flex;
5816
+ align-items: center;
5817
+ justify-content: space-between;
5818
+ padding: 20px 32px;
5819
+ border-bottom: 1px solid var(--border);
5820
+ background: var(--surface);
5821
+ position: sticky;
5822
+ top: 0;
5823
+ z-index: 10;
5726
5824
  }
5727
5825
 
5728
- .btn-secondary:hover {
5729
- background: #cbd5e0;
5730
- }
5826
+ .page-title { font-size: 15px; font-weight: 700; letter-spacing: -0.2px; }
5827
+ .page-sub { font-family: var(--mono); font-size: 11px; color: var(--text-3); margin-top: 2px; }
5731
5828
 
5732
- .btn-success {
5733
- background: #48bb78;
5734
- color: white;
5735
- }
5829
+ .topbar-actions { display: flex; align-items: center; gap: 10px; }
5736
5830
 
5737
- .btn-success:hover {
5738
- background: #38a169;
5831
+ .btn {
5832
+ font-family: var(--sans);
5833
+ font-weight: 700;
5834
+ font-size: 12px;
5835
+ padding: 8px 16px;
5836
+ border-radius: 8px;
5837
+ border: none;
5838
+ cursor: pointer;
5839
+ transition: all 0.15s ease;
5840
+ display: inline-flex;
5841
+ align-items: center;
5842
+ gap: 6px;
5843
+ text-decoration: none;
5739
5844
  }
5845
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
5740
5846
 
5741
- .btn:disabled {
5742
- opacity: 0.5;
5743
- cursor: not-allowed;
5847
+ .btn-ghost {
5848
+ background: var(--panel);
5849
+ color: var(--text-2);
5850
+ border: 1px solid var(--border);
5744
5851
  }
5852
+ .btn-ghost:hover { border-color: var(--border-hi); color: var(--text-1); }
5745
5853
 
5746
- .content {
5747
- background: rgba(255, 255, 255, 0.95);
5748
- backdrop-filter: blur(10px);
5749
- padding: 30px;
5750
- border-radius: 12px;
5751
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
5854
+ .btn-primary {
5855
+ background: var(--accent);
5856
+ color: #fff;
5752
5857
  }
5858
+ .btn-primary:hover:not(:disabled) { background: #ea580c; box-shadow: 0 0 20px rgba(249,115,22,0.35); }
5753
5859
 
5754
- .tabs {
5860
+ /* \u2500\u2500 Content \u2500\u2500 */
5861
+ .content { padding: 28px 32px; display: flex; flex-direction: column; gap: 24px; }
5862
+
5863
+ /* \u2500\u2500 Tabs (category selector) \u2500\u2500 */
5864
+ .tab-bar {
5755
5865
  display: flex;
5756
- gap: 10px;
5757
- margin-bottom: 30px;
5758
- border-bottom: 2px solid #e2e8f0;
5759
- padding-bottom: 10px;
5866
+ gap: 4px;
5867
+ background: var(--surface);
5868
+ border: 1px solid var(--border);
5869
+ border-radius: 10px;
5870
+ padding: 4px;
5871
+ flex-wrap: wrap;
5760
5872
  }
5761
5873
 
5762
- .tab {
5763
- padding: 10px 20px;
5874
+ .tab-btn {
5875
+ padding: 7px 14px;
5876
+ background: transparent;
5764
5877
  border: none;
5765
- background: none;
5766
- font-size: 14px;
5878
+ border-radius: 7px;
5879
+ color: var(--text-3);
5880
+ font-family: var(--sans);
5881
+ font-size: 12px;
5767
5882
  font-weight: 600;
5768
- color: #718096;
5769
5883
  cursor: pointer;
5770
- border-bottom: 3px solid transparent;
5771
- transition: all 0.2s;
5772
- }
5773
-
5774
- .tab.active {
5775
- color: #667eea;
5776
- border-bottom-color: #667eea;
5884
+ transition: all 0.15s ease;
5885
+ white-space: nowrap;
5886
+ display: flex;
5887
+ align-items: center;
5888
+ gap: 5px;
5777
5889
  }
5778
-
5779
- .tab:hover {
5780
- color: #667eea;
5890
+ .tab-btn:hover { color: var(--text-2); }
5891
+ .tab-btn.active {
5892
+ background: var(--panel);
5893
+ color: var(--text-1);
5894
+ border: 1px solid var(--border-hi);
5781
5895
  }
5782
-
5783
- .category-section {
5784
- display: none;
5896
+ .tab-btn .tab-dot {
5897
+ width: 6px; height: 6px;
5898
+ border-radius: 50%;
5899
+ background: var(--text-3);
5785
5900
  }
5901
+ .tab-btn.has-required .tab-dot { background: var(--amber); }
5902
+ .tab-btn.active .tab-dot { background: var(--accent); }
5786
5903
 
5787
- .category-section.active {
5788
- display: block;
5789
- }
5904
+ /* \u2500\u2500 Section \u2500\u2500 */
5905
+ .section { display: none; flex-direction: column; gap: 16px; }
5906
+ .section.active { display: flex; }
5790
5907
 
5791
- .category-header {
5908
+ .section-header {
5792
5909
  display: flex;
5793
- justify-content: space-between;
5794
5910
  align-items: center;
5795
- margin-bottom: 20px;
5796
- }
5797
-
5798
- .category-title {
5799
- font-size: 18px;
5800
- font-weight: 600;
5801
- color: #2d3748;
5802
- }
5803
-
5804
- .env-grid {
5805
- display: grid;
5806
- gap: 20px;
5911
+ gap: 12px;
5912
+ margin-bottom: 4px;
5807
5913
  }
5808
-
5809
- .env-item {
5810
- border: 1px solid #e2e8f0;
5914
+ .section-icon {
5915
+ width: 36px; height: 36px;
5916
+ background: var(--accent-lo);
5917
+ border: 1px solid var(--accent-md);
5811
5918
  border-radius: 8px;
5812
- padding: 20px;
5813
- transition: all 0.2s;
5919
+ display: grid;
5920
+ place-items: center;
5921
+ font-size: 16px;
5814
5922
  }
5923
+ .section-title { font-size: 15px; font-weight: 700; }
5924
+ .section-desc { font-family: var(--mono); font-size: 11px; color: var(--text-3); margin-top: 2px; }
5815
5925
 
5816
- .env-item:hover {
5817
- border-color: #cbd5e0;
5818
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
5926
+ /* \u2500\u2500 Env card \u2500\u2500 */
5927
+ .env-card {
5928
+ background: var(--panel);
5929
+ border: 1px solid var(--border);
5930
+ border-radius: var(--radius-lg);
5931
+ padding: 20px 24px;
5932
+ transition: border-color 0.15s;
5819
5933
  }
5934
+ .env-card:hover { border-color: var(--border-hi); }
5935
+ .env-card.has-value { border-color: rgba(249,115,22,0.15); }
5820
5936
 
5821
- .env-item-header {
5937
+ .env-card-head {
5822
5938
  display: flex;
5823
5939
  justify-content: space-between;
5824
5940
  align-items: flex-start;
5825
- margin-bottom: 10px;
5941
+ margin-bottom: 6px;
5826
5942
  }
5827
5943
 
5828
- .env-label {
5829
- font-weight: 600;
5830
- color: #2d3748;
5831
- font-size: 14px;
5944
+ .env-key {
5945
+ font-family: var(--mono);
5946
+ font-size: 13px;
5947
+ font-weight: 500;
5948
+ color: var(--text-1);
5832
5949
  display: flex;
5833
5950
  align-items: center;
5834
5951
  gap: 8px;
5835
5952
  }
5836
5953
 
5837
- .required-badge {
5838
- background: #fc8181;
5839
- color: white;
5840
- font-size: 10px;
5841
- padding: 2px 6px;
5954
+ .badge-required {
5955
+ font-family: var(--sans);
5956
+ font-size: 9px;
5957
+ font-weight: 700;
5958
+ letter-spacing: 0.08em;
5959
+ text-transform: uppercase;
5960
+ background: rgba(239,68,68,0.15);
5961
+ color: var(--red);
5962
+ border: 1px solid rgba(239,68,68,0.25);
5842
5963
  border-radius: 4px;
5964
+ padding: 2px 6px;
5965
+ }
5966
+ .badge-sensitive {
5967
+ font-size: 9px;
5843
5968
  font-weight: 700;
5969
+ font-family: var(--sans);
5970
+ letter-spacing: 0.08em;
5971
+ text-transform: uppercase;
5972
+ background: var(--amber-lo);
5973
+ color: var(--amber);
5974
+ border: 1px solid rgba(245,158,11,0.2);
5975
+ border-radius: 4px;
5976
+ padding: 2px 6px;
5844
5977
  }
5845
5978
 
5846
- .env-description {
5847
- font-size: 13px;
5848
- color: #718096;
5849
- margin-bottom: 10px;
5979
+ .env-desc {
5980
+ font-family: var(--mono);
5981
+ font-size: 11px;
5982
+ color: var(--text-3);
5983
+ margin-bottom: 14px;
5984
+ line-height: 1.5;
5850
5985
  }
5851
5986
 
5852
- .env-input-group {
5987
+ .env-input-row {
5853
5988
  display: flex;
5854
- gap: 10px;
5989
+ gap: 8px;
5855
5990
  align-items: center;
5856
5991
  }
5857
5992
 
5858
- .env-input {
5993
+ .env-input, .env-select {
5859
5994
  flex: 1;
5860
- padding: 10px 12px;
5861
- border: 1px solid #e2e8f0;
5862
- border-radius: 6px;
5863
- font-size: 14px;
5864
- font-family: 'Monaco', 'Courier New', monospace;
5865
- transition: all 0.2s;
5866
- }
5867
-
5868
- .env-input:focus {
5995
+ background: var(--surface);
5996
+ border: 1px solid var(--border-hi);
5997
+ border-radius: 8px;
5998
+ padding: 10px 14px;
5999
+ font-family: var(--mono);
6000
+ font-size: 13px;
6001
+ color: var(--text-1);
5869
6002
  outline: none;
5870
- border-color: #667eea;
5871
- box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
6003
+ transition: border-color 0.15s, box-shadow 0.15s;
6004
+ appearance: none;
5872
6005
  }
5873
-
5874
- .env-input.error {
5875
- border-color: #fc8181;
6006
+ .env-input:focus, .env-select:focus {
6007
+ border-color: var(--accent);
6008
+ box-shadow: 0 0 0 3px rgba(249,115,22,0.12);
5876
6009
  }
6010
+ .env-input.changed { border-color: rgba(249,115,22,0.5); }
6011
+ .env-input.invalid { border-color: var(--red); }
5877
6012
 
5878
- .env-actions {
5879
- display: flex;
5880
- gap: 5px;
5881
- }
6013
+ .env-select option { background: var(--panel); }
5882
6014
 
5883
- .icon-btn {
5884
- padding: 8px;
5885
- border: none;
5886
- background: #e2e8f0;
5887
- border-radius: 6px;
6015
+ .env-action-btn {
6016
+ width: 36px; height: 36px;
6017
+ border-radius: 8px;
6018
+ border: 1px solid var(--border-hi);
6019
+ background: var(--surface);
6020
+ color: var(--text-2);
5888
6021
  cursor: pointer;
5889
- transition: all 0.2s;
5890
- font-size: 16px;
5891
- }
5892
-
5893
- .icon-btn:hover {
5894
- background: #cbd5e0;
5895
- }
5896
-
5897
- .icon-btn.test {
5898
- background: #bee3f8;
5899
- color: #2c5282;
5900
- }
5901
-
5902
- .icon-btn.test:hover {
5903
- background: #90cdf4;
5904
- }
5905
-
5906
- .icon-btn.generate {
5907
- background: #c6f6d5;
5908
- color: #22543d;
5909
- }
5910
-
5911
- .icon-btn.generate:hover {
5912
- background: #9ae6b4;
6022
+ display: grid;
6023
+ place-items: center;
6024
+ font-size: 14px;
6025
+ transition: all 0.15s;
6026
+ flex-shrink: 0;
5913
6027
  }
6028
+ .env-action-btn:hover { background: var(--panel); color: var(--text-1); border-color: var(--border-hi); }
6029
+ .env-action-btn.test-btn:hover { background: var(--blue-lo); color: var(--blue); border-color: rgba(56,189,248,0.25); }
6030
+ .env-action-btn.gen-btn:hover { background: var(--green-lo); color: var(--green); border-color: rgba(34,197,94,0.25); }
5914
6031
 
5915
- .error-message {
5916
- color: #e53e3e;
5917
- font-size: 12px;
5918
- margin-top: 5px;
6032
+ .env-feedback {
6033
+ font-family: var(--mono);
6034
+ font-size: 11px;
6035
+ margin-top: 8px;
6036
+ min-height: 16px;
5919
6037
  }
6038
+ .env-feedback.error { color: var(--red); }
6039
+ .env-feedback.success { color: var(--green); }
5920
6040
 
5921
- .success-message {
5922
- color: #38a169;
5923
- font-size: 12px;
5924
- margin-top: 5px;
6041
+ /* \u2500\u2500 Toast \u2500\u2500 */
6042
+ .toast-zone {
6043
+ position: fixed;
6044
+ bottom: 24px;
6045
+ right: 24px;
6046
+ display: flex;
6047
+ flex-direction: column;
6048
+ gap: 8px;
6049
+ z-index: 100;
5925
6050
  }
5926
6051
 
5927
- .alert {
5928
- padding: 15px 20px;
5929
- border-radius: 8px;
5930
- margin-bottom: 20px;
6052
+ .toast {
6053
+ padding: 12px 18px;
6054
+ border-radius: 10px;
6055
+ font-size: 13px;
6056
+ font-weight: 600;
5931
6057
  display: flex;
5932
6058
  align-items: center;
5933
6059
  gap: 10px;
6060
+ animation: slideIn 0.2s ease;
6061
+ max-width: 380px;
5934
6062
  }
6063
+ .toast.success { background: var(--panel); border: 1px solid rgba(34,197,94,0.3); color: var(--green); }
6064
+ .toast.error { background: var(--panel); border: 1px solid rgba(239,68,68,0.3); color: var(--red); }
6065
+ .toast.warning { background: var(--panel); border: 1px solid rgba(245,158,11,0.3); color: var(--amber); }
6066
+ .toast.info { background: var(--panel); border: 1px solid rgba(56,189,248,0.3); color: var(--blue); }
5935
6067
 
5936
- .alert-warning {
5937
- background: #fef5e7;
5938
- border-left: 4px solid #f59e0b;
5939
- color: #92400e;
5940
- }
5941
-
5942
- .alert-info {
5943
- background: #eff6ff;
5944
- border-left: 4px solid #3b82f6;
5945
- color: #1e40af;
6068
+ @keyframes slideIn {
6069
+ from { opacity: 0; transform: translateY(8px); }
6070
+ to { opacity: 1; transform: translateY(0); }
5946
6071
  }
5947
6072
 
5948
- .alert-success {
5949
- background: #f0fdf4;
5950
- border-left: 4px solid #10b981;
5951
- color: #065f46;
6073
+ /* \u2500\u2500 Modal (test result) \u2500\u2500 */
6074
+ .modal-backdrop {
6075
+ display: none;
6076
+ position: fixed; inset: 0;
6077
+ background: rgba(0,0,0,0.6);
6078
+ z-index: 200;
6079
+ align-items: center;
6080
+ justify-content: center;
5952
6081
  }
6082
+ .modal-backdrop.open { display: flex; }
5953
6083
 
5954
- .loading {
5955
- text-align: center;
5956
- padding: 40px;
5957
- color: #718096;
6084
+ .modal {
6085
+ background: var(--surface);
6086
+ border: 1px solid var(--border-hi);
6087
+ border-radius: var(--radius-lg);
6088
+ padding: 28px;
6089
+ width: 420px;
6090
+ max-width: 90vw;
6091
+ box-shadow: 0 24px 64px rgba(0,0,0,0.5);
6092
+ }
6093
+ .modal-title { font-size: 15px; font-weight: 700; margin-bottom: 16px; }
6094
+ .modal-body { margin-bottom: 20px; }
6095
+ .modal-result {
6096
+ padding: 14px;
6097
+ border-radius: 8px;
6098
+ font-family: var(--mono);
6099
+ font-size: 12px;
5958
6100
  }
6101
+ .modal-result.ok { background: var(--green-lo); border: 1px solid rgba(34,197,94,0.2); color: var(--green); }
6102
+ .modal-result.fail { background: var(--red-lo); border: 1px solid rgba(239,68,68,0.2); color: var(--red); }
6103
+ .modal-footer { display: flex; justify-content: flex-end; }
5959
6104
 
6105
+ /* \u2500\u2500 Spinner \u2500\u2500 */
5960
6106
  .spinner {
5961
- border: 3px solid #e2e8f0;
5962
- border-top: 3px solid #667eea;
6107
+ width: 36px; height: 36px;
6108
+ border: 3px solid var(--border);
6109
+ border-top-color: var(--accent);
5963
6110
  border-radius: 50%;
5964
- width: 40px;
5965
- height: 40px;
5966
- animation: spin 1s linear infinite;
5967
- margin: 0 auto 20px;
6111
+ animation: spin 0.8s linear infinite;
6112
+ margin: 0 auto 16px;
5968
6113
  }
6114
+ @keyframes spin { to { transform: rotate(360deg); } }
5969
6115
 
5970
- @keyframes spin {
5971
- 0% { transform: rotate(0deg); }
5972
- 100% { transform: rotate(360deg); }
6116
+ .loading-state {
6117
+ text-align: center;
6118
+ padding: 60px 0;
6119
+ color: var(--text-3);
6120
+ font-family: var(--mono);
6121
+ font-size: 12px;
5973
6122
  }
5974
6123
 
5975
- .modal {
6124
+ /* \u2500\u2500 Restart banner \u2500\u2500 */
6125
+ .restart-banner {
5976
6126
  display: none;
5977
- position: fixed;
5978
- top: 0;
5979
- left: 0;
5980
- right: 0;
5981
- bottom: 0;
5982
- background: rgba(0, 0, 0, 0.5);
5983
- z-index: 1000;
6127
+ background: var(--amber-lo);
6128
+ border: 1px solid rgba(245,158,11,0.25);
6129
+ border-radius: 10px;
6130
+ padding: 12px 18px;
6131
+ font-size: 13px;
6132
+ color: var(--amber);
6133
+ font-weight: 600;
5984
6134
  align-items: center;
5985
- justify-content: center;
6135
+ gap: 10px;
5986
6136
  }
6137
+ .restart-banner.show { display: flex; }
6138
+ </style>
6139
+ </head>
6140
+ <body>
6141
+ <div class="shell">
5987
6142
 
5988
- .modal.show {
5989
- display: flex;
5990
- }
6143
+ <!-- Sidebar -->
6144
+ <aside>
6145
+ <div class="logo">
6146
+ <div class="logo-mark">Q</div>
6147
+ <div>
6148
+ <div class="logo-name">OpenQA</div>
6149
+ <div class="logo-version">v1.3.4</div>
6150
+ </div>
6151
+ </div>
5991
6152
 
5992
- .modal-content {
5993
- background: white;
5994
- padding: 30px;
5995
- border-radius: 12px;
5996
- max-width: 500px;
5997
- width: 90%;
5998
- box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
5999
- }
6153
+ <div class="nav-section">
6154
+ <div class="nav-label">Overview</div>
6155
+ <a class="nav-item" href="/">
6156
+ <span class="icon">\u{1F4CA}</span> Dashboard
6157
+ </a>
6158
+ <a class="nav-item" href="/sessions">
6159
+ <span class="icon">\u{1F9EA}</span> Sessions
6160
+ </a>
6161
+ <a class="nav-item" href="/issues">
6162
+ <span class="icon">\u{1F41B}</span> Issues
6163
+ </a>
6000
6164
 
6001
- .modal-header {
6002
- font-size: 20px;
6003
- font-weight: 600;
6004
- margin-bottom: 15px;
6005
- color: #2d3748;
6006
- }
6165
+ <div class="nav-label">Testing</div>
6166
+ <a class="nav-item" href="/tests">
6167
+ <span class="icon">\u26A1</span> Tests
6168
+ </a>
6169
+ <a class="nav-item" href="/coverage">
6170
+ <span class="icon">\u{1F4C8}</span> Coverage
6171
+ </a>
6172
+ <a class="nav-item" href="/kanban">
6173
+ <span class="icon">\u{1F4CB}</span> Kanban
6174
+ </a>
6007
6175
 
6008
- .modal-body {
6009
- margin-bottom: 20px;
6010
- color: #4a5568;
6011
- }
6176
+ <div class="nav-label">System</div>
6177
+ <a class="nav-item" href="/config">
6178
+ <span class="icon">\u2699\uFE0F</span> Config
6179
+ </a>
6180
+ <a class="nav-item active" href="/config/env">
6181
+ <span class="icon">\u{1F527}</span> Environment
6182
+ </a>
6183
+ <a class="nav-item" href="/logs">
6184
+ <span class="icon">\u{1F4DC}</span> Logs
6185
+ </a>
6186
+ </div>
6012
6187
 
6013
- .modal-footer {
6014
- display: flex;
6015
- gap: 10px;
6016
- justify-content: flex-end;
6017
- }
6018
- </style>
6019
- </head>
6020
- <body>
6021
- <div class="container">
6022
- <div class="header">
6023
- <h1>
6024
- <span>\u2699\uFE0F</span>
6188
+ <div class="sidebar-footer">
6189
+ <div style="font-family:var(--mono);font-size:11px;color:var(--text-3);">
6025
6190
  Environment Variables
6026
- </h1>
6027
- <div class="header-actions">
6028
- <a href="/config" class="btn btn-secondary">\u2190 Back to Config</a>
6029
- <button id="saveBtn" class="btn btn-success" disabled>\u{1F4BE} Save Changes</button>
6191
+ </div>
6192
+ </div>
6193
+ </aside>
6194
+
6195
+ <!-- Main -->
6196
+ <main>
6197
+ <div class="topbar">
6198
+ <div>
6199
+ <div class="page-title">Environment Variables</div>
6200
+ <div class="page-sub">Configure runtime variables for OpenQA</div>
6201
+ </div>
6202
+ <div class="topbar-actions">
6203
+ <a class="btn btn-ghost" href="/config">\u2190 Back to Config</a>
6204
+ <button id="saveBtn" class="btn btn-primary" disabled>
6205
+ \u{1F4BE} Save Changes
6206
+ </button>
6030
6207
  </div>
6031
6208
  </div>
6032
6209
 
6033
6210
  <div class="content">
6034
- <div id="loading" class="loading">
6211
+
6212
+ <!-- Restart banner -->
6213
+ <div class="restart-banner" id="restartBanner">
6214
+ \u26A0\uFE0F Some changes require a server restart to take effect.
6215
+ </div>
6216
+
6217
+ <!-- Loading -->
6218
+ <div class="loading-state" id="loadingState">
6035
6219
  <div class="spinner"></div>
6036
- <div>Loading environment variables...</div>
6220
+ Loading environment variables\u2026
6037
6221
  </div>
6038
6222
 
6039
- <div id="main" style="display: none;">
6040
- <div id="alerts"></div>
6041
-
6042
- <div class="tabs">
6043
- <button class="tab active" data-category="llm">\u{1F916} LLM</button>
6044
- <button class="tab" data-category="security">\u{1F512} Security</button>
6045
- <button class="tab" data-category="target">\u{1F3AF} Target App</button>
6046
- <button class="tab" data-category="github">\u{1F419} GitHub</button>
6047
- <button class="tab" data-category="web">\u{1F310} Web Server</button>
6048
- <button class="tab" data-category="agent">\u{1F916} Agent</button>
6049
- <button class="tab" data-category="database">\u{1F4BE} Database</button>
6050
- <button class="tab" data-category="notifications">\u{1F514} Notifications</button>
6051
- </div>
6223
+ <!-- Main content (hidden while loading) -->
6224
+ <div id="mainContent" style="display:none;flex-direction:column;gap:24px;">
6225
+
6226
+ <!-- Tab bar -->
6227
+ <div class="tab-bar" id="tabBar"></div>
6228
+
6229
+ <!-- Sections -->
6230
+ <div id="sections"></div>
6052
6231
 
6053
- <div id="categories"></div>
6054
6232
  </div>
6055
6233
  </div>
6056
- </div>
6234
+ </main>
6235
+ </div>
6057
6236
 
6058
- <!-- Test Result Modal -->
6059
- <div id="testModal" class="modal">
6060
- <div class="modal-content">
6061
- <div class="modal-header">Test Result</div>
6062
- <div class="modal-body" id="testResult"></div>
6063
- <div class="modal-footer">
6064
- <button class="btn btn-secondary" onclick="closeTestModal()">Close</button>
6065
- </div>
6237
+ <!-- Test result modal -->
6238
+ <div class="modal-backdrop" id="testModal">
6239
+ <div class="modal">
6240
+ <div class="modal-title">Connection Test</div>
6241
+ <div class="modal-body">
6242
+ <div class="modal-result" id="testResultBox">\u2026</div>
6243
+ </div>
6244
+ <div class="modal-footer">
6245
+ <button class="btn btn-ghost" onclick="closeModal()">Close</button>
6066
6246
  </div>
6067
6247
  </div>
6248
+ </div>
6068
6249
 
6069
- <script>
6070
- let envVariables = [];
6071
- let changedVariables = {};
6072
- let restartRequired = false;
6250
+ <!-- Toast zone -->
6251
+ <div class="toast-zone" id="toastZone"></div>
6073
6252
 
6074
- // Load environment variables
6075
- async function loadEnvVariables() {
6076
- try {
6077
- const response = await fetch('/api/env');
6078
- if (!response.ok) throw new Error('Failed to load variables');
6079
-
6080
- const data = await response.json();
6081
- envVariables = data.variables;
6082
-
6083
- renderCategories();
6084
- document.getElementById('loading').style.display = 'none';
6085
- document.getElementById('main').style.display = 'block';
6086
- } catch (error) {
6087
- showAlert('error', 'Failed to load environment variables: ' + error.message);
6088
- }
6089
- }
6253
+ <script>
6254
+ /* \u2500\u2500 State \u2500\u2500 */
6255
+ let envVars = [];
6256
+ let changed = {};
6257
+ let hasRequiredMissing = false;
6258
+
6259
+ const TABS = [
6260
+ { id: 'llm', label: '\u{1F916} LLM', desc: 'Language model provider & API keys' },
6261
+ { id: 'security', label: '\u{1F512} Security', desc: 'Authentication & JWT configuration' },
6262
+ { id: 'target', label: '\u{1F3AF} Target App', desc: 'Application under test settings' },
6263
+ { id: 'github', label: '\u{1F419} GitHub', desc: 'Repository & CI/CD integration' },
6264
+ { id: 'web', label: '\u{1F310} Web Server', desc: 'HTTP host, port & CORS settings' },
6265
+ { id: 'agent', label: '\u{1F916} Agent', desc: 'Autonomous agent behaviour' },
6266
+ { id: 'database', label: '\u{1F4BE} Database', desc: 'Persistence & storage' },
6267
+ { id: 'notifications', label: '\u{1F514} Notifications', desc: 'Slack & Discord webhooks' },
6268
+ ];
6269
+
6270
+ /* \u2500\u2500 Init \u2500\u2500 */
6271
+ async function init() {
6272
+ try {
6273
+ const res = await fetch('/api/env');
6274
+ if (!res.ok) { toast('error', 'Failed to load environment variables (status ' + res.status + ')'); return; }
6275
+ const data = await res.json();
6276
+ envVars = data.variables || [];
6277
+ renderAll();
6278
+ document.getElementById('loadingState').style.display = 'none';
6279
+ const mc = document.getElementById('mainContent');
6280
+ mc.style.display = 'flex';
6281
+ } catch (e) {
6282
+ toast('error', 'Network error \u2014 ' + e.message);
6283
+ }
6284
+ }
6090
6285
 
6091
- // Render categories
6092
- function renderCategories() {
6093
- const container = document.getElementById('categories');
6094
- const categories = [...new Set(envVariables.map(v => v.category))];
6095
-
6096
- categories.forEach((category, index) => {
6097
- const section = document.createElement('div');
6098
- section.className = 'category-section' + (index === 0 ? ' active' : '');
6099
- section.dataset.category = category;
6100
-
6101
- const vars = envVariables.filter(v => v.category === category);
6102
-
6103
- section.innerHTML = \`
6104
- <div class="category-header">
6105
- <div class="category-title">\${getCategoryTitle(category)}</div>
6106
- </div>
6107
- <div class="env-grid">
6108
- \${vars.map(v => renderEnvItem(v)).join('')}
6109
- </div>
6110
- \`;
6111
-
6112
- container.appendChild(section);
6113
- });
6114
- }
6286
+ /* \u2500\u2500 Render \u2500\u2500 */
6287
+ function renderAll() {
6288
+ renderTabBar();
6289
+ renderSections();
6290
+ activateTab(TABS[0].id);
6291
+ }
6115
6292
 
6116
- // Render single env item
6117
- function renderEnvItem(envVar) {
6118
- const inputType = envVar.type === 'password' ? 'password' : 'text';
6119
- const value = envVar.displayValue || '';
6120
-
6121
- return \`
6122
- <div class="env-item" data-key="\${envVar.key}">
6123
- <div class="env-item-header">
6124
- <div class="env-label">
6125
- \${envVar.key}
6126
- \${envVar.required ? '<span class="required-badge">REQUIRED</span>' : ''}
6127
- </div>
6128
- </div>
6129
- <div class="env-description">\${envVar.description}</div>
6130
- <div class="env-input-group">
6131
- \${envVar.type === 'select' ?
6132
- \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
6133
- <option value="">-- Select --</option>
6134
- \${envVar.options.map(opt =>
6135
- \`<option value="\${opt}" \${value === opt ? 'selected' : ''}>\${opt}</option>\`
6136
- ).join('')}
6137
- </select>\` :
6138
- envVar.type === 'boolean' ?
6139
- \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
6140
- <option value="">-- Select --</option>
6141
- <option value="true" \${value === 'true' ? 'selected' : ''}>true</option>
6142
- <option value="false" \${value === 'false' ? 'selected' : ''}>false</option>
6143
- </select>\` :
6144
- \`<input
6145
- type="\${inputType}"
6146
- class="env-input"
6147
- data-key="\${envVar.key}"
6148
- value="\${value}"
6149
- placeholder="\${envVar.placeholder || ''}"
6150
- onchange="handleChange(this)"
6151
- />\`
6152
- }
6153
- <div class="env-actions">
6154
- \${envVar.testable ? \`<button class="icon-btn test" onclick="testVariable('\${envVar.key}')" title="Test">\u{1F9EA}</button>\` : ''}
6155
- \${envVar.key === 'OPENQA_JWT_SECRET' ? \`<button class="icon-btn generate" onclick="generateSecret('\${envVar.key}')" title="Generate">\u{1F511}</button>\` : ''}
6156
- </div>
6157
- </div>
6158
- <div class="error-message" id="error-\${envVar.key}"></div>
6159
- <div class="success-message" id="success-\${envVar.key}"></div>
6293
+ function renderTabBar() {
6294
+ const bar = document.getElementById('tabBar');
6295
+ bar.innerHTML = TABS.map(t => {
6296
+ const vars = envVars.filter(v => v.category === t.id);
6297
+ const hasRequired = vars.some(v => v.required);
6298
+ return \`<button class="tab-btn\${hasRequired ? ' has-required' : ''}" data-tab="\${t.id}" onclick="activateTab('\${t.id}')">
6299
+ <span class="tab-dot"></span>
6300
+ \${t.label}
6301
+ </button>\`;
6302
+ }).join('');
6303
+ }
6304
+
6305
+ function renderSections() {
6306
+ const container = document.getElementById('sections');
6307
+ container.innerHTML = TABS.map(t => {
6308
+ const vars = envVars.filter(v => v.category === t.id);
6309
+ return \`<div class="section" id="section-\${t.id}">
6310
+ <div class="section-header">
6311
+ <div class="section-icon">\${t.label.split(' ')[0]}</div>
6312
+ <div>
6313
+ <div class="section-title">\${t.label.slice(t.label.indexOf(' ')+1)}</div>
6314
+ <div class="section-desc">\${t.desc}</div>
6160
6315
  </div>
6161
- \`;
6162
- }
6316
+ </div>
6317
+ \${vars.map(renderCard).join('')}
6318
+ \${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>' : ''}
6319
+ </div>\`;
6320
+ }).join('');
6321
+ }
6163
6322
 
6164
- // Handle input change
6165
- function handleChange(input) {
6166
- const key = input.dataset.key;
6167
- const value = input.value;
6168
-
6169
- changedVariables[key] = value;
6170
- document.getElementById('saveBtn').disabled = false;
6171
-
6172
- // Clear messages
6173
- document.getElementById(\`error-\${key}\`).textContent = '';
6174
- document.getElementById(\`success-\${key}\`).textContent = '';
6175
- }
6323
+ function renderCard(v) {
6324
+ const displayVal = v.displayValue || '';
6325
+ const isSensitive = v.sensitive;
6326
+ const inputType = (v.type === 'password' && !changed[v.key]) ? 'password' : 'text';
6327
+
6328
+ let inputHTML = '';
6329
+ if (v.type === 'select' || v.type === 'boolean') {
6330
+ const opts = v.type === 'boolean'
6331
+ ? [{ val: 'true', lbl: 'true' }, { val: 'false', lbl: 'false' }]
6332
+ : (v.options || []).map(o => ({ val: o, lbl: o }));
6333
+ inputHTML = \`<select class="env-select" data-key="\${v.key}" onchange="handleChange(this)">
6334
+ <option value="">\u2014 Select \u2014</option>
6335
+ \${opts.map(o => \`<option value="\${o.val}" \${displayVal === o.val ? 'selected' : ''}>\${o.lbl}</option>\`).join('')}
6336
+ </select>\`;
6337
+ } else {
6338
+ inputHTML = \`<input
6339
+ type="\${inputType}"
6340
+ class="env-input"
6341
+ data-key="\${v.key}"
6342
+ value="\${escHtml(displayVal)}"
6343
+ placeholder="\${escHtml(v.placeholder || '')}"
6344
+ oninput="handleChange(this)"
6345
+ autocomplete="off"
6346
+ />\`;
6347
+ }
6176
6348
 
6177
- // Save changes
6178
- async function saveChanges() {
6179
- const saveBtn = document.getElementById('saveBtn');
6180
- saveBtn.disabled = true;
6181
- saveBtn.textContent = '\u{1F4BE} Saving...';
6182
-
6183
- try {
6184
- const response = await fetch('/api/env/bulk', {
6185
- method: 'POST',
6186
- headers: { 'Content-Type': 'application/json' },
6187
- body: JSON.stringify({ variables: changedVariables }),
6188
- });
6189
-
6190
- if (!response.ok) {
6191
- const error = await response.json();
6192
- throw new Error(error.error || 'Failed to save');
6349
+ const testBtn = v.testable
6350
+ ? \`<button class="env-action-btn test-btn" onclick="testVar('\${v.key}')" title="Test connection">\u{1F9EA}</button>\`
6351
+ : '';
6352
+
6353
+ const genBtn = v.key === 'OPENQA_JWT_SECRET'
6354
+ ? \`<button class="env-action-btn gen-btn" onclick="generateSecret('\${v.key}')" title="Generate secret">\u{1F511}</button>\`
6355
+ : '';
6356
+
6357
+ const toggleBtn = (v.type === 'password' || isSensitive)
6358
+ ? \`<button class="env-action-btn" onclick="toggleVis('\${v.key}')" title="Toggle visibility" id="vis-\${v.key}">\u{1F441}</button>\`
6359
+ : '';
6360
+
6361
+ return \`<div class="env-card\${displayVal ? ' has-value' : ''}" id="card-\${v.key}">
6362
+ <div class="env-card-head">
6363
+ <div class="env-key">
6364
+ \${v.key}
6365
+ \${v.required ? '<span class="badge-required">Required</span>' : ''}
6366
+ \${isSensitive ? '<span class="badge-sensitive">Sensitive</span>' : ''}
6367
+ </div>
6368
+ </div>
6369
+ <div class="env-desc">\${v.description}</div>
6370
+ <div class="env-input-row">
6371
+ \${inputHTML}
6372
+ \${toggleBtn}
6373
+ \${testBtn}
6374
+ \${genBtn}
6375
+ </div>
6376
+ <div class="env-feedback" id="fb-\${v.key}"></div>
6377
+ </div>\`;
6378
+ }
6379
+
6380
+ /* \u2500\u2500 Tab switching \u2500\u2500 */
6381
+ function activateTab(id) {
6382
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === id));
6383
+ document.querySelectorAll('.section').forEach(s => s.classList.toggle('active', s.id === 'section-' + id));
6384
+ }
6385
+
6386
+ /* \u2500\u2500 Input handling \u2500\u2500 */
6387
+ function handleChange(el) {
6388
+ const key = el.dataset.key;
6389
+ const val = el.value;
6390
+ changed[key] = val;
6391
+ el.classList.add('changed');
6392
+ el.classList.remove('invalid');
6393
+ clearFeedback(key);
6394
+ document.getElementById('saveBtn').disabled = false;
6395
+ }
6396
+
6397
+ /* \u2500\u2500 Toggle password visibility \u2500\u2500 */
6398
+ function toggleVis(key) {
6399
+ const inp = document.querySelector('[data-key="' + key + '"]');
6400
+ if (!inp || inp.tagName !== 'INPUT') return;
6401
+ inp.type = inp.type === 'password' ? 'text' : 'password';
6402
+ }
6403
+
6404
+ /* \u2500\u2500 Save \u2500\u2500 */
6405
+ async function saveChanges() {
6406
+ if (!Object.keys(changed).length) return;
6407
+
6408
+ const btn = document.getElementById('saveBtn');
6409
+ btn.disabled = true;
6410
+ btn.textContent = '\u23F3 Saving\u2026';
6411
+
6412
+ try {
6413
+ const res = await fetch('/api/env/bulk', {
6414
+ method: 'POST',
6415
+ headers: { 'Content-Type': 'application/json' },
6416
+ body: JSON.stringify({ variables: changed }),
6417
+ credentials: 'include',
6418
+ });
6419
+
6420
+ const body = await res.json().catch(() => ({}));
6421
+
6422
+ if (!res.ok) {
6423
+ const errStr = body.errors
6424
+ ? Object.entries(body.errors).map(([k, v]) => k + ': ' + v).join('; ')
6425
+ : body.error || 'Failed to save';
6426
+ // Show per-field errors
6427
+ if (body.errors) {
6428
+ for (const [k, msg] of Object.entries(body.errors)) {
6429
+ setFeedback(k, 'error', msg);
6430
+ const inp = document.querySelector('[data-key="' + k + '"]');
6431
+ if (inp) inp.classList.add('invalid');
6193
6432
  }
6194
-
6195
- const result = await response.json();
6196
- restartRequired = result.restartRequired;
6197
-
6198
- showAlert('success', \`\u2705 Saved \${result.updated} variable(s) successfully!\` +
6199
- (restartRequired ? ' \u26A0\uFE0F Restart required for changes to take effect.' : ''));
6200
-
6201
- changedVariables = {};
6202
- saveBtn.textContent = '\u{1F4BE} Save Changes';
6203
-
6204
- // Reload to show updated values
6205
- setTimeout(() => location.reload(), 2000);
6206
- } catch (error) {
6207
- showAlert('error', 'Failed to save: ' + error.message);
6208
- saveBtn.disabled = false;
6209
- saveBtn.textContent = '\u{1F4BE} Save Changes';
6210
6433
  }
6434
+ toast('error', errStr);
6435
+ btn.disabled = false;
6436
+ btn.innerHTML = '\u{1F4BE} Save Changes';
6437
+ return;
6211
6438
  }
6212
6439
 
6213
- // Test variable
6214
- async function testVariable(key) {
6215
- const input = document.querySelector(\`[data-key="\${key}"]\`);
6216
- const value = input.value;
6217
-
6218
- if (!value) {
6219
- showAlert('warning', 'Please enter a value first');
6220
- return;
6221
- }
6222
-
6223
- try {
6224
- const response = await fetch(\`/api/env/test/\${key}\`, {
6225
- method: 'POST',
6226
- headers: { 'Content-Type': 'application/json' },
6227
- body: JSON.stringify({ value }),
6228
- });
6229
-
6230
- const result = await response.json();
6231
- showTestResult(result);
6232
- } catch (error) {
6233
- showTestResult({ success: false, message: 'Test failed: ' + error.message });
6234
- }
6440
+ toast('success', '\u2705 Saved ' + body.updated + ' variable(s)');
6441
+ if (body.restartRequired) {
6442
+ document.getElementById('restartBanner').classList.add('show');
6235
6443
  }
6236
6444
 
6237
- // Generate secret
6238
- async function generateSecret(key) {
6239
- try {
6240
- const response = await fetch(\`/api/env/generate/\${key}\`, {
6241
- method: 'POST',
6242
- });
6243
-
6244
- if (!response.ok) throw new Error('Failed to generate');
6245
-
6246
- const result = await response.json();
6247
- const input = document.querySelector(\`[data-key="\${key}"]\`);
6248
- input.value = result.value;
6249
- handleChange(input);
6250
-
6251
- document.getElementById(\`success-\${key}\`).textContent = '\u2705 Secret generated!';
6252
- } catch (error) {
6253
- document.getElementById(\`error-\${key}\`).textContent = 'Failed to generate: ' + error.message;
6254
- }
6255
- }
6445
+ changed = {};
6446
+ btn.innerHTML = '\u{1F4BE} Save Changes';
6447
+ // Reload to reflect masked values
6448
+ setTimeout(() => location.reload(), 1200);
6449
+ } catch (e) {
6450
+ toast('error', 'Network error \u2014 ' + e.message);
6451
+ btn.disabled = false;
6452
+ btn.innerHTML = '\u{1F4BE} Save Changes';
6453
+ }
6454
+ }
6256
6455
 
6257
- // Show test result
6258
- function showTestResult(result) {
6259
- const modal = document.getElementById('testModal');
6260
- const resultDiv = document.getElementById('testResult');
6261
-
6262
- resultDiv.innerHTML = \`
6263
- <div class="alert \${result.success ? 'alert-success' : 'alert-warning'}">
6264
- \${result.success ? '\u2705' : '\u274C'} \${result.message}
6265
- </div>
6266
- \`;
6267
-
6268
- modal.classList.add('show');
6269
- }
6456
+ /* \u2500\u2500 Test variable \u2500\u2500 */
6457
+ async function testVar(key) {
6458
+ const inp = document.querySelector('[data-key="' + key + '"]');
6459
+ const val = inp ? inp.value : '';
6460
+ if (!val) { toast('warning', 'Enter a value first'); return; }
6270
6461
 
6271
- function closeTestModal() {
6272
- document.getElementById('testModal').classList.remove('show');
6273
- }
6462
+ setFeedback(key, '', '');
6463
+ const btn = document.querySelector('[onclick="testVar(\\''+key+'\\')"]');
6464
+ if (btn) { btn.textContent = '\u23F3'; btn.disabled = true; }
6274
6465
 
6275
- // Show alert
6276
- function showAlert(type, message) {
6277
- const alerts = document.getElementById('alerts');
6278
- const alertClass = type === 'error' ? 'alert-warning' :
6279
- type === 'success' ? 'alert-success' : 'alert-info';
6280
-
6281
- alerts.innerHTML = \`
6282
- <div class="alert \${alertClass}">
6283
- \${message}
6284
- </div>
6285
- \`;
6286
-
6287
- setTimeout(() => alerts.innerHTML = '', 5000);
6288
- }
6289
-
6290
- // Get category title
6291
- function getCategoryTitle(category) {
6292
- const titles = {
6293
- llm: '\u{1F916} LLM Configuration',
6294
- security: '\u{1F512} Security Settings',
6295
- target: '\u{1F3AF} Target Application',
6296
- github: '\u{1F419} GitHub Integration',
6297
- web: '\u{1F310} Web Server',
6298
- agent: '\u{1F916} Agent Configuration',
6299
- database: '\u{1F4BE} Database',
6300
- notifications: '\u{1F514} Notifications',
6301
- };
6302
- return titles[category] || category;
6303
- }
6304
-
6305
- // Tab switching
6306
- document.addEventListener('click', (e) => {
6307
- if (e.target.classList.contains('tab')) {
6308
- const category = e.target.dataset.category;
6309
-
6310
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
6311
- e.target.classList.add('active');
6312
-
6313
- document.querySelectorAll('.category-section').forEach(s => s.classList.remove('active'));
6314
- document.querySelector(\`[data-category="\${category}"]\`).classList.add('active');
6315
- }
6466
+ try {
6467
+ const res = await fetch('/api/env/test/' + key, {
6468
+ method: 'POST',
6469
+ headers: { 'Content-Type': 'application/json' },
6470
+ body: JSON.stringify({ value: val }),
6471
+ credentials: 'include',
6316
6472
  });
6473
+ const result = await res.json();
6474
+ openModal(result.success, result.message);
6475
+ setFeedback(key, result.success ? 'success' : 'error', result.success ? '\u2713 Connected' : '\u2717 ' + result.message);
6476
+ } catch (e) {
6477
+ openModal(false, 'Network error: ' + e.message);
6478
+ } finally {
6479
+ if (btn) { btn.textContent = '\u{1F9EA}'; btn.disabled = false; }
6480
+ }
6481
+ }
6482
+
6483
+ /* \u2500\u2500 Generate secret \u2500\u2500 */
6484
+ async function generateSecret(key) {
6485
+ try {
6486
+ const res = await fetch('/api/env/generate/' + key, {
6487
+ method: 'POST', credentials: 'include'
6488
+ });
6489
+ if (!res.ok) throw new Error('Failed to generate');
6490
+ const { value } = await res.json();
6491
+ const inp = document.querySelector('[data-key="' + key + '"]');
6492
+ if (inp) {
6493
+ inp.type = 'text';
6494
+ inp.value = value;
6495
+ handleChange(inp);
6496
+ }
6497
+ setFeedback(key, 'success', '\u2713 Secret generated \u2014 save to persist');
6498
+ } catch (e) {
6499
+ setFeedback(key, 'error', e.message);
6500
+ }
6501
+ }
6502
+
6503
+ /* \u2500\u2500 Modal \u2500\u2500 */
6504
+ function openModal(ok, msg) {
6505
+ const box = document.getElementById('testResultBox');
6506
+ box.className = 'modal-result ' + (ok ? 'ok' : 'fail');
6507
+ box.textContent = (ok ? '\u2713 ' : '\u2717 ') + msg;
6508
+ document.getElementById('testModal').classList.add('open');
6509
+ }
6510
+ function closeModal() {
6511
+ document.getElementById('testModal').classList.remove('open');
6512
+ }
6317
6513
 
6318
- // Save button
6319
- document.getElementById('saveBtn').addEventListener('click', saveChanges);
6514
+ /* \u2500\u2500 Toast \u2500\u2500 */
6515
+ function toast(type, msg) {
6516
+ const zone = document.getElementById('toastZone');
6517
+ const el = document.createElement('div');
6518
+ el.className = 'toast ' + type;
6519
+ el.textContent = msg;
6520
+ zone.appendChild(el);
6521
+ setTimeout(() => el.remove(), 4500);
6522
+ }
6320
6523
 
6321
- // Load on page load
6322
- loadEnvVariables();
6323
- </script>
6524
+ /* \u2500\u2500 Feedback \u2500\u2500 */
6525
+ function setFeedback(key, type, msg) {
6526
+ const el = document.getElementById('fb-' + key);
6527
+ if (!el) return;
6528
+ el.className = 'env-feedback' + (type ? ' ' + type : '');
6529
+ el.textContent = msg;
6530
+ }
6531
+ function clearFeedback(key) { setFeedback(key, '', ''); }
6532
+
6533
+ /* \u2500\u2500 Helpers \u2500\u2500 */
6534
+ function escHtml(s) {
6535
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
6536
+ }
6537
+
6538
+ /* \u2500\u2500 Wire save button \u2500\u2500 */
6539
+ document.getElementById('saveBtn').addEventListener('click', saveChanges);
6540
+
6541
+ /* \u2500\u2500 Close modal on backdrop click \u2500\u2500 */
6542
+ document.getElementById('testModal').addEventListener('click', function(e) {
6543
+ if (e.target === this) closeModal();
6544
+ });
6545
+
6546
+ /* \u2500\u2500 Boot \u2500\u2500 */
6547
+ init();
6548
+ </script>
6324
6549
  </body>
6325
6550
  </html>`;
6326
6551
  }