@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.
@@ -861,9 +861,9 @@ function createQuickConfig(name, description, url, options) {
861
861
  // agent/brain/index.ts
862
862
  init_esm_shims();
863
863
  import { EventEmitter as EventEmitter2 } from "events";
864
- import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
865
- import { join as join3 } from "path";
866
- import { execSync } from "child_process";
864
+ import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
865
+ import { join as join4 } from "path";
866
+ import { execSync as execSync2 } from "child_process";
867
867
 
868
868
  // agent/brain/llm-resilience.ts
869
869
  init_esm_shims();
@@ -1101,10 +1101,150 @@ var ResilientLLM = class extends EventEmitter {
1101
1101
  }
1102
1102
  };
1103
1103
 
1104
+ // agent/brain/diff-analyzer.ts
1105
+ init_esm_shims();
1106
+ import { execSync } from "child_process";
1107
+ import { existsSync as existsSync2 } from "fs";
1108
+ import { join as join3, basename, dirname as dirname2 } from "path";
1109
+ var DiffAnalyzer = class {
1110
+ /**
1111
+ * Get files changed between current branch and base branch
1112
+ */
1113
+ getChangedFiles(repoPath, baseBranch = "main") {
1114
+ try {
1115
+ const output = execSync(`git diff --name-only ${baseBranch}...HEAD`, {
1116
+ cwd: repoPath,
1117
+ stdio: "pipe"
1118
+ }).toString().trim();
1119
+ if (!output) return [];
1120
+ return output.split("\n").filter(Boolean);
1121
+ } catch {
1122
+ try {
1123
+ const output = execSync("git diff --name-only HEAD~1", {
1124
+ cwd: repoPath,
1125
+ stdio: "pipe"
1126
+ }).toString().trim();
1127
+ if (!output) return [];
1128
+ return output.split("\n").filter(Boolean);
1129
+ } catch {
1130
+ return [];
1131
+ }
1132
+ }
1133
+ }
1134
+ /**
1135
+ * Map changed source files to their likely test files
1136
+ */
1137
+ mapFilesToTests(changedFiles, repoPath) {
1138
+ const testFiles = /* @__PURE__ */ new Set();
1139
+ for (const file of changedFiles) {
1140
+ if (!this.isSourceFile(file)) continue;
1141
+ const candidates = this.getTestCandidates(file);
1142
+ for (const candidate of candidates) {
1143
+ if (existsSync2(join3(repoPath, candidate))) {
1144
+ testFiles.add(candidate);
1145
+ }
1146
+ }
1147
+ }
1148
+ for (const file of changedFiles) {
1149
+ if (this.isTestFile(file)) {
1150
+ testFiles.add(file);
1151
+ }
1152
+ }
1153
+ return Array.from(testFiles);
1154
+ }
1155
+ /**
1156
+ * Analyze a diff and return risk assessment + affected tests
1157
+ */
1158
+ analyze(repoPath, baseBranch = "main") {
1159
+ const changedFiles = this.getChangedFiles(repoPath, baseBranch);
1160
+ const affectedTests = this.mapFilesToTests(changedFiles, repoPath);
1161
+ const riskLevel = this.assessRisk(changedFiles);
1162
+ const summary = this.buildSummary(changedFiles, affectedTests, riskLevel);
1163
+ return { changedFiles, affectedTests, riskLevel, summary };
1164
+ }
1165
+ getTestCandidates(filePath) {
1166
+ const dir = dirname2(filePath);
1167
+ const base = basename(filePath);
1168
+ const candidates = [];
1169
+ const nameMatch = base.match(/^(.+)\.(tsx?|jsx?|vue|svelte|py|go|rs)$/);
1170
+ if (!nameMatch) return candidates;
1171
+ const name = nameMatch[1];
1172
+ const ext = nameMatch[2];
1173
+ const testExts = ext.startsWith("ts") ? ["test.ts", "test.tsx", "spec.ts", "spec.tsx"] : ext.startsWith("js") ? ["test.js", "test.jsx", "spec.js", "spec.jsx"] : ext === "py" ? ["test.py"] : ext === "go" ? ["_test.go"] : [];
1174
+ for (const testExt of testExts) {
1175
+ candidates.push(join3(dir, `${name}.${testExt}`));
1176
+ candidates.push(join3(dir, "__tests__", `${name}.${testExt}`));
1177
+ candidates.push(join3(dir, "test", `${name}.${testExt}`));
1178
+ candidates.push(join3(dir, "tests", `${name}.${testExt}`));
1179
+ candidates.push(join3("__tests__", dir, `${name}.${testExt}`));
1180
+ }
1181
+ if (ext === "go") {
1182
+ candidates.push(join3(dir, `${name}_test.go`));
1183
+ }
1184
+ return candidates;
1185
+ }
1186
+ isSourceFile(file) {
1187
+ return /\.(tsx?|jsx?|vue|svelte|py|go|rs)$/.test(file) && !this.isTestFile(file);
1188
+ }
1189
+ isTestFile(file) {
1190
+ return /\.(test|spec)\.(tsx?|jsx?|py)$/.test(file) || /_test\.go$/.test(file) || file.includes("__tests__/");
1191
+ }
1192
+ assessRisk(changedFiles) {
1193
+ const highRiskPatterns = [
1194
+ /auth/i,
1195
+ /security/i,
1196
+ /middleware/i,
1197
+ /database/i,
1198
+ /migration/i,
1199
+ /config/i,
1200
+ /\.env/,
1201
+ /package\.json$/,
1202
+ /docker/i,
1203
+ /ci\//i,
1204
+ /payment/i,
1205
+ /billing/i,
1206
+ /permission/i
1207
+ ];
1208
+ const mediumRiskPatterns = [
1209
+ /api/i,
1210
+ /route/i,
1211
+ /controller/i,
1212
+ /service/i,
1213
+ /model/i,
1214
+ /hook/i,
1215
+ /context/i,
1216
+ /store/i,
1217
+ /util/i
1218
+ ];
1219
+ let highCount = 0;
1220
+ let mediumCount = 0;
1221
+ for (const file of changedFiles) {
1222
+ if (highRiskPatterns.some((p) => p.test(file))) highCount++;
1223
+ else if (mediumRiskPatterns.some((p) => p.test(file))) mediumCount++;
1224
+ }
1225
+ if (highCount >= 2 || highCount >= 1 && changedFiles.length > 5) return "high";
1226
+ if (highCount >= 1 || mediumCount >= 3) return "medium";
1227
+ return "low";
1228
+ }
1229
+ buildSummary(changedFiles, affectedTests, riskLevel) {
1230
+ const lines = [];
1231
+ lines.push(`${changedFiles.length} file(s) changed, ${affectedTests.length} test(s) affected.`);
1232
+ lines.push(`Risk level: ${riskLevel}.`);
1233
+ if (affectedTests.length > 0) {
1234
+ lines.push(`Run: ${affectedTests.slice(0, 5).join(", ")}${affectedTests.length > 5 ? ` (+${affectedTests.length - 5} more)` : ""}`);
1235
+ } else if (changedFiles.length > 0) {
1236
+ lines.push("No matching test files found \u2014 consider running full suite.");
1237
+ }
1238
+ return lines.join(" ");
1239
+ }
1240
+ };
1241
+
1104
1242
  // agent/brain/index.ts
1105
1243
  var OpenQABrain = class extends EventEmitter2 {
1106
1244
  db;
1107
1245
  llm;
1246
+ cache = new LLMCache();
1247
+ diffAnalyzer = new DiffAnalyzer();
1108
1248
  saasConfig;
1109
1249
  generatedTests = /* @__PURE__ */ new Map();
1110
1250
  dynamicAgents = /* @__PURE__ */ new Map();
@@ -1165,7 +1305,9 @@ Respond in JSON format:
1165
1305
  "suggestedAgents": ["Agent for X", "Agent for Y"],
1166
1306
  "risks": ["Risk 1", "Risk 2"]
1167
1307
  }`;
1168
- const response = await this.llm.generate(prompt);
1308
+ const cached = this.cache.get(prompt);
1309
+ const response = cached ?? await this.llm.generate(prompt);
1310
+ if (!cached) this.cache.set(prompt, response);
1169
1311
  try {
1170
1312
  const jsonMatch = response.match(/\{[\s\S]*\}/);
1171
1313
  if (jsonMatch) {
@@ -1184,11 +1326,11 @@ Respond in JSON format:
1184
1326
  async analyzeCodebase() {
1185
1327
  let repoPath = this.saasConfig.localPath;
1186
1328
  if (this.saasConfig.repoUrl && !this.saasConfig.localPath) {
1187
- repoPath = join3(this.workDir, "repo");
1188
- if (!existsSync2(repoPath)) {
1329
+ repoPath = join4(this.workDir, "repo");
1330
+ if (!existsSync3(repoPath)) {
1189
1331
  logger.info("Cloning repository", { url: this.saasConfig.repoUrl });
1190
1332
  try {
1191
- execSync(`git clone --depth 1 ${this.saasConfig.repoUrl} ${repoPath}`, {
1333
+ execSync2(`git clone --depth 1 ${this.saasConfig.repoUrl} ${repoPath}`, {
1192
1334
  stdio: "pipe"
1193
1335
  });
1194
1336
  } catch (e) {
@@ -1197,13 +1339,13 @@ Respond in JSON format:
1197
1339
  }
1198
1340
  }
1199
1341
  }
1200
- if (!repoPath || !existsSync2(repoPath)) {
1342
+ if (!repoPath || !existsSync3(repoPath)) {
1201
1343
  return "";
1202
1344
  }
1203
1345
  const analysis = [];
1204
1346
  try {
1205
- const packageJsonPath = join3(repoPath, "package.json");
1206
- if (existsSync2(packageJsonPath)) {
1347
+ const packageJsonPath = join4(repoPath, "package.json");
1348
+ if (existsSync3(packageJsonPath)) {
1207
1349
  const pkg = JSON.parse(readFileSync3(packageJsonPath, "utf-8"));
1208
1350
  analysis.push(`### Package Info`);
1209
1351
  analysis.push(`- Name: ${pkg.name}`);
@@ -1228,8 +1370,8 @@ Respond in JSON format:
1228
1370
  }
1229
1371
  const routePatterns = ["routes", "pages", "views", "controllers", "api"];
1230
1372
  for (const pattern of routePatterns) {
1231
- const routeDir = join3(repoPath, "src", pattern);
1232
- if (existsSync2(routeDir)) {
1373
+ const routeDir = join4(repoPath, "src", pattern);
1374
+ if (existsSync3(routeDir)) {
1233
1375
  const routes = this.findFiles(routeDir, [".ts", ".tsx", ".js", ".jsx"], 20);
1234
1376
  if (routes.length > 0) {
1235
1377
  analysis.push(`
@@ -1256,7 +1398,7 @@ Respond in JSON format:
1256
1398
  for (const item of items) {
1257
1399
  if (results.length >= limit) return;
1258
1400
  if (item.startsWith(".") || item === "node_modules" || item === "dist" || item === "build") continue;
1259
- const fullPath = join3(d, item);
1401
+ const fullPath = join4(d, item);
1260
1402
  const stat = statSync2(fullPath);
1261
1403
  if (stat.isDirectory()) {
1262
1404
  find(fullPath);
@@ -1302,7 +1444,9 @@ Respond with JSON:
1302
1444
  "code": "// complete test code here",
1303
1445
  "priority": 1-5
1304
1446
  }`;
1305
- const response = await this.llm.generate(prompt);
1447
+ const cached2 = this.cache.get(prompt);
1448
+ const response = cached2 ?? await this.llm.generate(prompt);
1449
+ if (!cached2) this.cache.set(prompt, response);
1306
1450
  let testData = { name: target, description: "", code: "", priority: 3 };
1307
1451
  try {
1308
1452
  const jsonMatch = response.match(/\{[\s\S]*\}/);
@@ -1329,7 +1473,7 @@ Respond with JSON:
1329
1473
  }
1330
1474
  saveTest(test) {
1331
1475
  const filename = `${test.type}_${test.id}.ts`;
1332
- const filepath = join3(this.testsDir, filename);
1476
+ const filepath = join4(this.testsDir, filename);
1333
1477
  const content = `/**
1334
1478
  * Generated by OpenQA
1335
1479
  * Type: ${test.type}
@@ -1367,7 +1511,9 @@ Respond with JSON:
1367
1511
  "prompt": "Complete system prompt for this agent (be specific and detailed)",
1368
1512
  "tools": ["tool1", "tool2"] // from: navigate, click, fill, screenshot, check_console, create_issue, create_ticket
1369
1513
  }`;
1370
- const response = await this.llm.generate(prompt);
1514
+ const cached3 = this.cache.get(prompt);
1515
+ const response = cached3 ?? await this.llm.generate(prompt);
1516
+ if (!cached3) this.cache.set(prompt, response);
1371
1517
  let agentData = { name: purpose, purpose, prompt: "", tools: [] };
1372
1518
  try {
1373
1519
  const jsonMatch = response.match(/\{[\s\S]*\}/);
@@ -1398,8 +1544,8 @@ Respond with JSON:
1398
1544
  test.executedAt = /* @__PURE__ */ new Date();
1399
1545
  this.emit("test-started", test);
1400
1546
  try {
1401
- if (test.targetFile && existsSync2(test.targetFile)) {
1402
- const result = execSync(`npx playwright test ${test.targetFile} --reporter=json`, {
1547
+ if (test.targetFile && existsSync3(test.targetFile)) {
1548
+ const result = execSync2(`npx playwright test ${test.targetFile} --reporter=json`, {
1403
1549
  cwd: this.testsDir,
1404
1550
  stdio: "pipe",
1405
1551
  timeout: 12e4
@@ -1453,7 +1599,9 @@ Respond with JSON:
1453
1599
  { "type": "analyze", "target": "what to analyze", "reason": "why" }
1454
1600
  ]
1455
1601
  }`;
1456
- const response = await this.llm.generate(prompt);
1602
+ const cached4 = this.cache.get(prompt);
1603
+ const response = cached4 ?? await this.llm.generate(prompt);
1604
+ if (!cached4) this.cache.set(prompt, response);
1457
1605
  try {
1458
1606
  const jsonMatch = response.match(/\{[\s\S]*\}/);
1459
1607
  if (jsonMatch) {
@@ -1545,13 +1693,50 @@ Respond with JSON:
1545
1693
  }
1546
1694
  };
1547
1695
  }
1696
+ /**
1697
+ * B8 — Incremental mode: only generate tests for files changed vs baseBranch.
1698
+ * Emits 'diff-analyzed' with the DiffResult, then runs autonomously with a reduced
1699
+ * scope derived from the changed files.
1700
+ */
1701
+ async runIncrementally(baseBranch = "main") {
1702
+ const repoPath = this.saasConfig.localPath;
1703
+ if (!repoPath) {
1704
+ logger.warn("runIncrementally: no localPath configured, falling back to full run");
1705
+ return this.runAutonomously();
1706
+ }
1707
+ const diff = this.diffAnalyzer.analyze(repoPath, baseBranch);
1708
+ logger.info("Incremental diff", { changedFiles: diff.changedFiles.length, affectedTests: diff.affectedTests.length, riskLevel: diff.riskLevel });
1709
+ this.emit("diff-analyzed", diff);
1710
+ if (diff.changedFiles.length === 0) {
1711
+ logger.info("No changed files \u2014 skipping incremental run");
1712
+ this.emit("session-complete", { testsGenerated: 0, agentsCreated: 0, incremental: true });
1713
+ return;
1714
+ }
1715
+ for (const file of diff.changedFiles.slice(0, 10)) {
1716
+ const testType = this.inferTestType(file);
1717
+ const context = `Changed file: ${file}
1718
+ Risk level: ${diff.riskLevel}
1719
+ ${diff.summary}`;
1720
+ try {
1721
+ await this.generateTest(testType, `Test coverage for ${file}`, context);
1722
+ } catch (e) {
1723
+ logger.error("Failed to generate test for file", { file, error: e instanceof Error ? e.message : String(e) });
1724
+ }
1725
+ }
1726
+ this.emit("session-complete", {
1727
+ testsGenerated: this.generatedTests.size,
1728
+ agentsCreated: this.dynamicAgents.size,
1729
+ incremental: true,
1730
+ diff
1731
+ });
1732
+ }
1548
1733
  };
1549
1734
 
1550
1735
  // agent/tools/browser.ts
1551
1736
  init_esm_shims();
1552
1737
  import { chromium } from "playwright";
1553
1738
  import { mkdirSync as mkdirSync3 } from "fs";
1554
- import { join as join4 } from "path";
1739
+ import { join as join5 } from "path";
1555
1740
  var BrowserTools = class {
1556
1741
  browser = null;
1557
1742
  page = null;
@@ -1576,13 +1761,9 @@ var BrowserTools = class {
1576
1761
  {
1577
1762
  name: "navigate_to_page",
1578
1763
  description: "Navigate to a specific URL in the application",
1579
- parameters: {
1580
- type: "object",
1581
- properties: {
1582
- url: { type: "string", description: "The URL to navigate to" }
1583
- },
1584
- required: ["url"]
1585
- },
1764
+ parameters: [
1765
+ { name: "url", type: "string", description: "The URL to navigate to", required: true }
1766
+ ],
1586
1767
  execute: async ({ url }) => {
1587
1768
  if (!this.page) await this.initialize();
1588
1769
  try {
@@ -1595,24 +1776,20 @@ var BrowserTools = class {
1595
1776
  input: url,
1596
1777
  output: `Page title: ${title}`
1597
1778
  });
1598
- return `Successfully navigated to ${url}. Page title: "${title}"`;
1779
+ return { output: `Successfully navigated to ${url}. Page title: "${title}"` };
1599
1780
  } catch (error) {
1600
- return `Failed to navigate: ${error instanceof Error ? error.message : String(error)}`;
1781
+ return { output: `Failed to navigate: ${error instanceof Error ? error.message : String(error)}`, error: error instanceof Error ? error.message : String(error) };
1601
1782
  }
1602
1783
  }
1603
1784
  },
1604
1785
  {
1605
1786
  name: "click_element",
1606
1787
  description: "Click on an element using a CSS selector",
1607
- parameters: {
1608
- type: "object",
1609
- properties: {
1610
- selector: { type: "string", description: "CSS selector of the element to click" }
1611
- },
1612
- required: ["selector"]
1613
- },
1788
+ parameters: [
1789
+ { name: "selector", type: "string", description: "CSS selector of the element to click", required: true }
1790
+ ],
1614
1791
  execute: async ({ selector }) => {
1615
- if (!this.page) return "Browser not initialized. Navigate to a page first.";
1792
+ if (!this.page) return { output: "Browser not initialized. Navigate to a page first.", error: "Browser not initialized" };
1616
1793
  try {
1617
1794
  await this.page.click(selector, { timeout: 5e3 });
1618
1795
  this.db.createAction({
@@ -1621,25 +1798,21 @@ var BrowserTools = class {
1621
1798
  description: `Clicked element: ${selector}`,
1622
1799
  input: selector
1623
1800
  });
1624
- return `Successfully clicked element: ${selector}`;
1801
+ return { output: `Successfully clicked element: ${selector}` };
1625
1802
  } catch (error) {
1626
- return `Failed to click element: ${error instanceof Error ? error.message : String(error)}`;
1803
+ return { output: `Failed to click element: ${error instanceof Error ? error.message : String(error)}`, error: error instanceof Error ? error.message : String(error) };
1627
1804
  }
1628
1805
  }
1629
1806
  },
1630
1807
  {
1631
1808
  name: "fill_input",
1632
1809
  description: "Fill an input field with text",
1633
- parameters: {
1634
- type: "object",
1635
- properties: {
1636
- selector: { type: "string", description: "CSS selector of the input field" },
1637
- text: { type: "string", description: "Text to fill in the input" }
1638
- },
1639
- required: ["selector", "text"]
1640
- },
1810
+ parameters: [
1811
+ { name: "selector", type: "string", description: "CSS selector of the input field", required: true },
1812
+ { name: "text", type: "string", description: "Text to fill in the input", required: true }
1813
+ ],
1641
1814
  execute: async ({ selector, text }) => {
1642
- if (!this.page) return "Browser not initialized. Navigate to a page first.";
1815
+ if (!this.page) return { output: "Browser not initialized. Navigate to a page first.", error: "Browser not initialized" };
1643
1816
  try {
1644
1817
  await this.page.fill(selector, text);
1645
1818
  this.db.createAction({
@@ -1648,27 +1821,23 @@ var BrowserTools = class {
1648
1821
  description: `Filled input ${selector}`,
1649
1822
  input: `${selector} = ${text}`
1650
1823
  });
1651
- return `Successfully filled input ${selector} with text`;
1824
+ return { output: `Successfully filled input ${selector} with text` };
1652
1825
  } catch (error) {
1653
- return `Failed to fill input: ${error instanceof Error ? error.message : String(error)}`;
1826
+ return { output: `Failed to fill input: ${error instanceof Error ? error.message : String(error)}`, error: error instanceof Error ? error.message : String(error) };
1654
1827
  }
1655
1828
  }
1656
1829
  },
1657
1830
  {
1658
1831
  name: "take_screenshot",
1659
1832
  description: "Take a screenshot of the current page for evidence",
1660
- parameters: {
1661
- type: "object",
1662
- properties: {
1663
- name: { type: "string", description: "Name for the screenshot file" }
1664
- },
1665
- required: ["name"]
1666
- },
1833
+ parameters: [
1834
+ { name: "name", type: "string", description: "Name for the screenshot file", required: true }
1835
+ ],
1667
1836
  execute: async ({ name }) => {
1668
- if (!this.page) return "Browser not initialized. Navigate to a page first.";
1837
+ if (!this.page) return { output: "Browser not initialized. Navigate to a page first.", error: "Browser not initialized" };
1669
1838
  try {
1670
1839
  const filename = `${Date.now()}_${name}.png`;
1671
- const path2 = join4(this.screenshotDir, filename);
1840
+ const path2 = join5(this.screenshotDir, filename);
1672
1841
  await this.page.screenshot({ path: path2, fullPage: true });
1673
1842
  this.db.createAction({
1674
1843
  session_id: this.sessionId,
@@ -1676,38 +1845,32 @@ var BrowserTools = class {
1676
1845
  description: `Screenshot: ${name}`,
1677
1846
  screenshot_path: path2
1678
1847
  });
1679
- return `Screenshot saved: ${path2}`;
1848
+ return { output: `Screenshot saved: ${path2}` };
1680
1849
  } catch (error) {
1681
- return `Failed to take screenshot: ${error instanceof Error ? error.message : String(error)}`;
1850
+ return { output: `Failed to take screenshot: ${error instanceof Error ? error.message : String(error)}`, error: error instanceof Error ? error.message : String(error) };
1682
1851
  }
1683
1852
  }
1684
1853
  },
1685
1854
  {
1686
1855
  name: "get_page_content",
1687
1856
  description: "Get the text content of the current page",
1688
- parameters: {
1689
- type: "object",
1690
- properties: {}
1691
- },
1857
+ parameters: [],
1692
1858
  execute: async () => {
1693
- if (!this.page) return "Browser not initialized. Navigate to a page first.";
1859
+ if (!this.page) return { output: "Browser not initialized. Navigate to a page first.", error: "Browser not initialized" };
1694
1860
  try {
1695
1861
  const content = await this.page.textContent("body");
1696
- return content?.slice(0, 1e3) || "No content found";
1862
+ return { output: content?.slice(0, 1e3) || "No content found" };
1697
1863
  } catch (error) {
1698
- return `Failed to get content: ${error instanceof Error ? error.message : String(error)}`;
1864
+ return { output: `Failed to get content: ${error instanceof Error ? error.message : String(error)}`, error: error instanceof Error ? error.message : String(error) };
1699
1865
  }
1700
1866
  }
1701
1867
  },
1702
1868
  {
1703
1869
  name: "check_console_errors",
1704
1870
  description: "Check for JavaScript console errors on the page",
1705
- parameters: {
1706
- type: "object",
1707
- properties: {}
1708
- },
1871
+ parameters: [],
1709
1872
  execute: async () => {
1710
- if (!this.page) return "Browser not initialized. Navigate to a page first.";
1873
+ if (!this.page) return { output: "Browser not initialized. Navigate to a page first.", error: "Browser not initialized" };
1711
1874
  const errors = [];
1712
1875
  this.page.on("console", (msg) => {
1713
1876
  if (msg.type() === "error") {
@@ -1716,10 +1879,10 @@ var BrowserTools = class {
1716
1879
  });
1717
1880
  await this.page.waitForTimeout(2e3);
1718
1881
  if (errors.length > 0) {
1719
- return `Found ${errors.length} console errors:
1720
- ${errors.join("\n")}`;
1882
+ return { output: `Found ${errors.length} console errors:
1883
+ ${errors.join("\n")}` };
1721
1884
  }
1722
- return "No console errors detected";
1885
+ return { output: "No console errors detected" };
1723
1886
  }
1724
1887
  }
1725
1888
  ];
@@ -1830,6 +1993,16 @@ var GitListener = class extends EventEmitter3 {
1830
1993
  for (const commit of commits) {
1831
1994
  if (this.lastCommitSha && commit.sha === this.lastCommitSha) break;
1832
1995
  const isMerge = commit.parents && commit.parents.length > 1;
1996
+ let changedFiles;
1997
+ try {
1998
+ const { data: detail } = await this.octokit.repos.getCommit({
1999
+ owner: this.config.owner,
2000
+ repo: this.config.repo,
2001
+ ref: commit.sha
2002
+ });
2003
+ changedFiles = (detail.files ?? []).map((f) => f.filename);
2004
+ } catch {
2005
+ }
1833
2006
  const event = {
1834
2007
  type: isMerge ? "merge" : "push",
1835
2008
  provider: "github",
@@ -1837,7 +2010,8 @@ var GitListener = class extends EventEmitter3 {
1837
2010
  commit: commit.sha,
1838
2011
  author: commit.commit.author?.name || "unknown",
1839
2012
  message: commit.commit.message,
1840
- timestamp: new Date(commit.commit.author?.date || Date.now())
2013
+ timestamp: new Date(commit.commit.author?.date || Date.now()),
2014
+ changedFiles
1841
2015
  };
1842
2016
  this.emit("git-event", event);
1843
2017
  if (isMerge) {
@@ -1923,6 +2097,16 @@ var GitListener = class extends EventEmitter3 {
1923
2097
  for (const commit of commits) {
1924
2098
  if (this.lastCommitSha && commit.id === this.lastCommitSha) break;
1925
2099
  const isMerge = commit.parent_ids && commit.parent_ids.length > 1;
2100
+ let changedFiles;
2101
+ try {
2102
+ const diffRes = await fetch(
2103
+ `${baseUrl}/api/v4/projects/${projectPath}/repository/commits/${commit.id}/diff`,
2104
+ { headers }
2105
+ );
2106
+ const diffs = await diffRes.json();
2107
+ changedFiles = diffs.map((d) => d.new_path);
2108
+ } catch {
2109
+ }
1926
2110
  const event = {
1927
2111
  type: isMerge ? "merge" : "push",
1928
2112
  provider: "gitlab",
@@ -1930,7 +2114,8 @@ var GitListener = class extends EventEmitter3 {
1930
2114
  commit: commit.id,
1931
2115
  author: commit.author_name,
1932
2116
  message: commit.message,
1933
- timestamp: new Date(commit.created_at)
2117
+ timestamp: new Date(commit.created_at),
2118
+ changedFiles
1934
2119
  };
1935
2120
  this.emit("git-event", event);
1936
2121
  if (isMerge) {
@@ -2025,8 +2210,8 @@ var GitListener = class extends EventEmitter3 {
2025
2210
  init_esm_shims();
2026
2211
  import { EventEmitter as EventEmitter4 } from "events";
2027
2212
  import { spawn } from "child_process";
2028
- import { existsSync as existsSync3, readFileSync as readFileSync4, statSync } from "fs";
2029
- import { join as join5, resolve } from "path";
2213
+ import { existsSync as existsSync4, readFileSync as readFileSync4, statSync } from "fs";
2214
+ import { join as join6, resolve } from "path";
2030
2215
  function sanitizeRepoPath(inputPath) {
2031
2216
  if (typeof inputPath !== "string" || !inputPath.trim()) {
2032
2217
  throw new ProjectRunnerError("repoPath must be a non-empty string");
@@ -2054,14 +2239,14 @@ var ProjectRunner = class extends EventEmitter4 {
2054
2239
  */
2055
2240
  detectProjectType(repoPath) {
2056
2241
  repoPath = sanitizeRepoPath(repoPath);
2057
- const pkgPath = join5(repoPath, "package.json");
2058
- if (existsSync3(pkgPath)) {
2242
+ const pkgPath = join6(repoPath, "package.json");
2243
+ if (existsSync4(pkgPath)) {
2059
2244
  return this.detectNodeProject(repoPath, pkgPath);
2060
2245
  }
2061
- if (existsSync3(join5(repoPath, "requirements.txt")) || existsSync3(join5(repoPath, "pyproject.toml"))) {
2246
+ if (existsSync4(join6(repoPath, "requirements.txt")) || existsSync4(join6(repoPath, "pyproject.toml"))) {
2062
2247
  return this.detectPythonProject(repoPath);
2063
2248
  }
2064
- if (existsSync3(join5(repoPath, "go.mod"))) {
2249
+ if (existsSync4(join6(repoPath, "go.mod"))) {
2065
2250
  return {
2066
2251
  language: "go",
2067
2252
  packageManager: "go",
@@ -2070,7 +2255,7 @@ var ProjectRunner = class extends EventEmitter4 {
2070
2255
  devCommand: "go run ."
2071
2256
  };
2072
2257
  }
2073
- if (existsSync3(join5(repoPath, "Cargo.toml"))) {
2258
+ if (existsSync4(join6(repoPath, "Cargo.toml"))) {
2074
2259
  return {
2075
2260
  language: "rust",
2076
2261
  packageManager: "cargo",
@@ -2086,9 +2271,9 @@ var ProjectRunner = class extends EventEmitter4 {
2086
2271
  const scripts = pkg.scripts || {};
2087
2272
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
2088
2273
  let packageManager = "npm";
2089
- if (existsSync3(join5(repoPath, "pnpm-lock.yaml"))) packageManager = "pnpm";
2090
- else if (existsSync3(join5(repoPath, "yarn.lock"))) packageManager = "yarn";
2091
- else if (existsSync3(join5(repoPath, "bun.lockb"))) packageManager = "bun";
2274
+ if (existsSync4(join6(repoPath, "pnpm-lock.yaml"))) packageManager = "pnpm";
2275
+ else if (existsSync4(join6(repoPath, "yarn.lock"))) packageManager = "yarn";
2276
+ else if (existsSync4(join6(repoPath, "bun.lockb"))) packageManager = "bun";
2092
2277
  let framework;
2093
2278
  if (deps["next"]) framework = "next";
2094
2279
  else if (deps["nuxt"]) framework = "nuxt";
@@ -2140,13 +2325,13 @@ var ProjectRunner = class extends EventEmitter4 {
2140
2325
  }
2141
2326
  detectPythonProject(repoPath) {
2142
2327
  let testRunner;
2143
- const reqPath = join5(repoPath, "requirements.txt");
2144
- if (existsSync3(reqPath)) {
2328
+ const reqPath = join6(repoPath, "requirements.txt");
2329
+ if (existsSync4(reqPath)) {
2145
2330
  const content = readFileSync4(reqPath, "utf-8");
2146
2331
  if (content.includes("pytest")) testRunner = "pytest";
2147
2332
  }
2148
- const pyprojectPath = join5(repoPath, "pyproject.toml");
2149
- if (existsSync3(pyprojectPath)) {
2333
+ const pyprojectPath = join6(repoPath, "pyproject.toml");
2334
+ if (existsSync4(pyprojectPath)) {
2150
2335
  const content = readFileSync4(pyprojectPath, "utf-8");
2151
2336
  if (content.includes("pytest")) testRunner = "pytest";
2152
2337
  }
@@ -3931,8 +4116,8 @@ function createAuthRouter(db2) {
3931
4116
  // cli/env-routes.ts
3932
4117
  init_esm_shims();
3933
4118
  import { Router as Router3 } from "express";
3934
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "fs";
3935
- import { join as join6 } from "path";
4119
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, existsSync as existsSync5 } from "fs";
4120
+ import { join as join7 } from "path";
3936
4121
 
3937
4122
  // cli/env-config.ts
3938
4123
  init_esm_shims();
@@ -4321,9 +4506,9 @@ function validateEnvValue(key, value) {
4321
4506
  // cli/env-routes.ts
4322
4507
  function createEnvRouter() {
4323
4508
  const router = Router3();
4324
- const ENV_FILE_PATH = join6(process.cwd(), ".env");
4509
+ const ENV_FILE_PATH = join7(process.cwd(), ".env");
4325
4510
  function readEnvFile() {
4326
- if (!existsSync4(ENV_FILE_PATH)) {
4511
+ if (!existsSync5(ENV_FILE_PATH)) {
4327
4512
  return {};
4328
4513
  }
4329
4514
  const content = readFileSync5(ENV_FILE_PATH, "utf-8");
@@ -4398,8 +4583,8 @@ function createEnvRouter() {
4398
4583
  }));
4399
4584
  res.json({
4400
4585
  variables,
4401
- envFileExists: existsSync4(ENV_FILE_PATH),
4402
- lastModified: existsSync4(ENV_FILE_PATH) ? new Date(readFileSync5(ENV_FILE_PATH, "utf-8").match(/Last updated: (.+)/)?.[1] || 0).toISOString() : null
4586
+ envFileExists: existsSync5(ENV_FILE_PATH),
4587
+ lastModified: existsSync5(ENV_FILE_PATH) ? new Date(readFileSync5(ENV_FILE_PATH, "utf-8").match(/Last updated: (.+)/)?.[1] || 0).toISOString() : null
4403
4588
  });
4404
4589
  } catch (error) {
4405
4590
  res.status(500).json({
@@ -4759,7 +4944,7 @@ function getDashboardHTML() {
4759
4944
  .logo-mark {
4760
4945
  width: 34px;
4761
4946
  height: 34px;
4762
- background: var(--accent);
4947
+ background: transparent;
4763
4948
  border-radius: 8px;
4764
4949
  display: grid;
4765
4950
  place-items: center;
@@ -5381,7 +5566,9 @@ function getDashboardHTML() {
5381
5566
  <!-- Sidebar -->
5382
5567
  <aside>
5383
5568
  <div class="logo">
5384
- <div class="logo-mark">\u{1F52C}</div>
5569
+ <div class="logo-mark">
5570
+ <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;">
5571
+ </div>
5385
5572
  <div>
5386
5573
  <div class="logo-name">OpenQA</div>
5387
5574
  <div class="logo-version">v2.1.0 \xB7 OSS</div>
@@ -5391,42 +5578,69 @@ function getDashboardHTML() {
5391
5578
  <div class="nav-section">
5392
5579
  <div class="nav-label">Overview</div>
5393
5580
  <a class="nav-item active" href="/">
5394
- <span class="icon">\u25A6</span> Dashboard
5581
+ <span class="icon">
5582
+ <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>
5583
+ </span> Dashboard
5395
5584
  </a>
5396
5585
  <a class="nav-item" href="/kanban">
5397
- <span class="icon">\u229E</span> Kanban
5586
+ <span class="icon">
5587
+ <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>
5588
+ </span> Kanban
5398
5589
  <span class="badge" id="kanban-count">0</span>
5399
5590
  </a>
5400
5591
 
5401
5592
  <div class="nav-label">Agents</div>
5402
5593
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('agents-table')">
5403
- <span class="icon">\u25CE</span> Active Agents
5594
+ <span class="icon">
5595
+ <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>
5596
+ </span> Active Agents
5404
5597
  </a>
5405
5598
  <a class="nav-item" href="javascript:void(0)" onclick="switchAgentTab('specialists'); scrollToSection('agents-table')">
5406
- <span class="icon">\u25C7</span> Specialists
5599
+ <span class="icon">
5600
+ <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>
5601
+ </span> Specialists
5407
5602
  </a>
5408
5603
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('interventions-panel')">
5409
- <span class="icon">\u26A0</span> Interventions
5604
+ <span class="icon">
5605
+ <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>
5606
+ </span> Interventions
5410
5607
  <span class="badge" id="intervention-count" style="background: var(--red);">0</span>
5411
5608
  </a>
5412
5609
 
5413
5610
  <div class="nav-label">Analysis</div>
5414
5611
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('issues-panel')">
5415
- <span class="icon">\u{1F41B}</span> Bug Reports
5612
+ <span class="icon">
5613
+ <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"/>
5614
+ <path d="M14.12 3.88 16 2"/>
5615
+ <path d="M21 5a4 4 0 0 1-3.55 3.97"/>
5616
+ <path d="M3 21a4 4 0 0 1 3.81-4"/>
5617
+ <path d="M3 5a4 4 0 0 0 3.55 3.97"/>
5618
+ <path d="M6 13H2"/><path d="m8 2 1.88 1.88"/>
5619
+ <path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/>
5620
+ </svg>
5621
+ </span> Bug Reports
5416
5622
  </a>
5417
5623
  <a class="nav-item" href="javascript:void(0)" onclick="switchChartTab('performance'); scrollToSection('chart-performance')">
5418
- <span class="icon">\u26A1</span> Performance
5624
+ <span class="icon">
5625
+ <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>
5626
+ </span> Performance
5419
5627
  </a>
5420
5628
  <a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('activity-list')">
5421
- <span class="icon">\u{1F4CB}</span> Logs
5629
+ <span class="icon">
5630
+ <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>
5631
+ </span> Logs
5422
5632
  </a>
5423
5633
 
5424
5634
  <div class="nav-label">System</div>
5425
5635
  <a class="nav-item" href="/config">
5426
- <span class="icon">\u2699</span> Config
5636
+ <span class="icon">
5637
+ <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>
5638
+ </span> Config
5427
5639
  </a>
5428
5640
  <a class="nav-item" href="/config/env">
5429
- <span class="icon">\u{1F527}</span> Environment
5641
+ <span class="icon">
5642
+ <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>
5643
+ </span> Environment
5430
5644
  </a>
5431
5645
  </div>
5432
5646
 
@@ -5458,7 +5672,9 @@ function getDashboardHTML() {
5458
5672
  <div class="metric-card">
5459
5673
  <div class="metric-header">
5460
5674
  <div class="metric-label">Active Agents</div>
5461
- <div class="metric-icon">\u{1F916}</div>
5675
+ <div class="metric-icon">
5676
+ <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>
5677
+ </div>
5462
5678
  </div>
5463
5679
  <div class="metric-value" id="active-agents">0</div>
5464
5680
  <div class="metric-change positive" id="agents-change">\u2191 0 from last hour</div>
@@ -5466,7 +5682,9 @@ function getDashboardHTML() {
5466
5682
  <div class="metric-card">
5467
5683
  <div class="metric-header">
5468
5684
  <div class="metric-label">Total Actions</div>
5469
- <div class="metric-icon">\u26A1</div>
5685
+ <div class="metric-icon">
5686
+ <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>
5687
+ </div>
5470
5688
  </div>
5471
5689
  <div class="metric-value" id="total-actions">0</div>
5472
5690
  <div class="metric-change positive" id="actions-change">\u2191 0% this session</div>
@@ -5474,7 +5692,9 @@ function getDashboardHTML() {
5474
5692
  <div class="metric-card">
5475
5693
  <div class="metric-header">
5476
5694
  <div class="metric-label">Bugs Found</div>
5477
- <div class="metric-icon">\u{1F41B}</div>
5695
+ <div class="metric-icon">
5696
+ <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>
5697
+ </div>
5478
5698
  </div>
5479
5699
  <div class="metric-value" id="bugs-found">0</div>
5480
5700
  <div class="metric-change negative" id="bugs-change">\u2193 0 from yesterday</div>
@@ -5482,7 +5702,9 @@ function getDashboardHTML() {
5482
5702
  <div class="metric-card">
5483
5703
  <div class="metric-header">
5484
5704
  <div class="metric-label">Success Rate</div>
5485
- <div class="metric-icon">\u2713</div>
5705
+ <div class="metric-icon">
5706
+ <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>
5707
+ </div>
5486
5708
  </div>
5487
5709
  <div class="metric-value" id="success-rate">\u2014</div>
5488
5710
  <div class="metric-change positive" id="rate-change">\u2191 0 pts improvement</div>
@@ -7721,672 +7943,860 @@ function getEnvHTML() {
7721
7943
  <head>
7722
7944
  <meta charset="UTF-8">
7723
7945
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7724
- <title>Environment Variables - OpenQA</title>
7946
+ <title>OpenQA \u2014 Environment</title>
7947
+ <link rel="preconnect" href="https://fonts.googleapis.com">
7948
+ <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">
7725
7949
  <style>
7726
- * { margin: 0; padding: 0; box-sizing: border-box; }
7727
-
7950
+ :root {
7951
+ --bg: #080b10;
7952
+ --surface: #0d1117;
7953
+ --panel: #111720;
7954
+ --border: rgba(255,255,255,0.06);
7955
+ --border-hi: rgba(255,255,255,0.12);
7956
+ --accent: #f97316;
7957
+ --accent-lo: rgba(249,115,22,0.08);
7958
+ --accent-md: rgba(249,115,22,0.18);
7959
+ --green: #22c55e;
7960
+ --green-lo: rgba(34,197,94,0.08);
7961
+ --red: #ef4444;
7962
+ --red-lo: rgba(239,68,68,0.08);
7963
+ --amber: #f59e0b;
7964
+ --amber-lo: rgba(245,158,11,0.08);
7965
+ --blue: #38bdf8;
7966
+ --blue-lo: rgba(56,189,248,0.08);
7967
+ --text-1: #f1f5f9;
7968
+ --text-2: #8b98a8;
7969
+ --text-3: #4b5563;
7970
+ --mono: 'DM Mono', monospace;
7971
+ --sans: 'Syne', sans-serif;
7972
+ --radius: 10px;
7973
+ --radius-lg: 16px;
7974
+ }
7975
+
7976
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
7977
+
7728
7978
  body {
7729
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
7730
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
7979
+ font-family: var(--sans);
7980
+ background: var(--bg);
7981
+ color: var(--text-1);
7731
7982
  min-height: 100vh;
7732
- padding: 20px;
7983
+ overflow-x: hidden;
7733
7984
  }
7734
7985
 
7735
- .container {
7736
- max-width: 1200px;
7737
- margin: 0 auto;
7986
+ /* \u2500\u2500 Layout \u2500\u2500 */
7987
+ .shell {
7988
+ display: grid;
7989
+ grid-template-columns: 220px 1fr;
7990
+ min-height: 100vh;
7738
7991
  }
7739
7992
 
7740
- .header {
7741
- background: rgba(255, 255, 255, 0.95);
7742
- backdrop-filter: blur(10px);
7743
- padding: 20px 30px;
7744
- border-radius: 12px;
7745
- margin-bottom: 20px;
7746
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
7993
+ /* \u2500\u2500 Sidebar \u2500\u2500 */
7994
+ aside {
7995
+ background: var(--surface);
7996
+ border-right: 1px solid var(--border);
7747
7997
  display: flex;
7748
- justify-content: space-between;
7749
- align-items: center;
7998
+ flex-direction: column;
7999
+ padding: 28px 0;
8000
+ position: sticky;
8001
+ top: 0;
8002
+ height: 100vh;
7750
8003
  }
7751
8004
 
7752
- .header h1 {
7753
- font-size: 24px;
7754
- color: #1a202c;
8005
+ .logo {
7755
8006
  display: flex;
7756
8007
  align-items: center;
7757
8008
  gap: 10px;
8009
+ padding: 0 24px 32px;
8010
+ border-bottom: 1px solid var(--border);
8011
+ margin-bottom: 12px;
7758
8012
  }
7759
8013
 
7760
- .header-actions {
7761
- display: flex;
7762
- gap: 10px;
8014
+ .logo-mark {
8015
+ width: 34px; height: 34px;
8016
+ background: var(--accent);
8017
+ border-radius: 8px;
8018
+ display: grid;
8019
+ place-items: center;
8020
+ font-size: 17px;
8021
+ font-weight: 800;
8022
+ color: #fff;
7763
8023
  }
7764
8024
 
7765
- .btn {
7766
- padding: 10px 20px;
7767
- border: none;
7768
- border-radius: 8px;
8025
+ .logo-name { font-weight: 800; font-size: 18px; letter-spacing: -0.5px; }
8026
+ .logo-version { font-family: var(--mono); font-size: 10px; color: var(--text-3); }
8027
+
8028
+ .nav-section { padding: 8px 12px; flex: 1; overflow-y: auto; }
8029
+
8030
+ .nav-label {
8031
+ font-family: var(--mono);
8032
+ font-size: 10px;
8033
+ color: var(--text-3);
8034
+ letter-spacing: 1.5px;
8035
+ text-transform: uppercase;
8036
+ padding: 0 12px;
8037
+ margin: 16px 0 6px;
8038
+ }
8039
+
8040
+ .nav-item {
8041
+ display: flex;
8042
+ align-items: center;
8043
+ gap: 10px;
8044
+ padding: 9px 12px;
8045
+ border-radius: var(--radius);
8046
+ color: var(--text-2);
8047
+ text-decoration: none;
7769
8048
  font-size: 14px;
7770
8049
  font-weight: 600;
8050
+ transition: all 0.15s ease;
7771
8051
  cursor: pointer;
7772
- transition: all 0.2s;
7773
- text-decoration: none;
7774
- display: inline-flex;
7775
- align-items: center;
7776
- gap: 8px;
7777
8052
  }
8053
+ .nav-item:hover { color: var(--text-1); background: var(--panel); }
8054
+ .nav-item.active { color: var(--accent); background: var(--accent-lo); }
8055
+ .nav-item .icon { font-size: 15px; width: 20px; text-align: center; }
7778
8056
 
7779
- .btn-primary {
7780
- background: #667eea;
7781
- color: white;
8057
+ .sidebar-footer {
8058
+ padding: 16px 24px;
8059
+ border-top: 1px solid var(--border);
7782
8060
  }
7783
8061
 
7784
- .btn-primary:hover {
7785
- background: #5568d3;
7786
- transform: translateY(-1px);
7787
- }
8062
+ /* \u2500\u2500 Main \u2500\u2500 */
8063
+ main { display: flex; flex-direction: column; min-height: 100vh; overflow-y: auto; }
7788
8064
 
7789
- .btn-secondary {
7790
- background: #e2e8f0;
7791
- color: #4a5568;
8065
+ .topbar {
8066
+ display: flex;
8067
+ align-items: center;
8068
+ justify-content: space-between;
8069
+ padding: 20px 32px;
8070
+ border-bottom: 1px solid var(--border);
8071
+ background: var(--surface);
8072
+ position: sticky;
8073
+ top: 0;
8074
+ z-index: 10;
7792
8075
  }
7793
8076
 
7794
- .btn-secondary:hover {
7795
- background: #cbd5e0;
7796
- }
8077
+ .page-title { font-size: 15px; font-weight: 700; letter-spacing: -0.2px; }
8078
+ .page-sub { font-family: var(--mono); font-size: 11px; color: var(--text-3); margin-top: 2px; }
7797
8079
 
7798
- .btn-success {
7799
- background: #48bb78;
7800
- color: white;
7801
- }
8080
+ .topbar-actions { display: flex; align-items: center; gap: 10px; }
7802
8081
 
7803
- .btn-success:hover {
7804
- background: #38a169;
8082
+ .btn {
8083
+ font-family: var(--sans);
8084
+ font-weight: 700;
8085
+ font-size: 12px;
8086
+ padding: 8px 16px;
8087
+ border-radius: 8px;
8088
+ border: none;
8089
+ cursor: pointer;
8090
+ transition: all 0.15s ease;
8091
+ display: inline-flex;
8092
+ align-items: center;
8093
+ gap: 6px;
8094
+ text-decoration: none;
7805
8095
  }
8096
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
7806
8097
 
7807
- .btn:disabled {
7808
- opacity: 0.5;
7809
- cursor: not-allowed;
8098
+ .btn-ghost {
8099
+ background: var(--panel);
8100
+ color: var(--text-2);
8101
+ border: 1px solid var(--border);
7810
8102
  }
8103
+ .btn-ghost:hover { border-color: var(--border-hi); color: var(--text-1); }
7811
8104
 
7812
- .content {
7813
- background: rgba(255, 255, 255, 0.95);
7814
- backdrop-filter: blur(10px);
7815
- padding: 30px;
7816
- border-radius: 12px;
7817
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
8105
+ .btn-primary {
8106
+ background: var(--accent);
8107
+ color: #fff;
7818
8108
  }
8109
+ .btn-primary:hover:not(:disabled) { background: #ea580c; box-shadow: 0 0 20px rgba(249,115,22,0.35); }
7819
8110
 
7820
- .tabs {
8111
+ /* \u2500\u2500 Content \u2500\u2500 */
8112
+ .content { padding: 28px 32px; display: flex; flex-direction: column; gap: 24px; }
8113
+
8114
+ /* \u2500\u2500 Tabs (category selector) \u2500\u2500 */
8115
+ .tab-bar {
7821
8116
  display: flex;
7822
- gap: 10px;
7823
- margin-bottom: 30px;
7824
- border-bottom: 2px solid #e2e8f0;
7825
- padding-bottom: 10px;
8117
+ gap: 4px;
8118
+ background: var(--surface);
8119
+ border: 1px solid var(--border);
8120
+ border-radius: 10px;
8121
+ padding: 4px;
8122
+ flex-wrap: wrap;
7826
8123
  }
7827
8124
 
7828
- .tab {
7829
- padding: 10px 20px;
8125
+ .tab-btn {
8126
+ padding: 7px 14px;
8127
+ background: transparent;
7830
8128
  border: none;
7831
- background: none;
7832
- font-size: 14px;
8129
+ border-radius: 7px;
8130
+ color: var(--text-3);
8131
+ font-family: var(--sans);
8132
+ font-size: 12px;
7833
8133
  font-weight: 600;
7834
- color: #718096;
7835
8134
  cursor: pointer;
7836
- border-bottom: 3px solid transparent;
7837
- transition: all 0.2s;
7838
- }
7839
-
7840
- .tab.active {
7841
- color: #667eea;
7842
- border-bottom-color: #667eea;
8135
+ transition: all 0.15s ease;
8136
+ white-space: nowrap;
8137
+ display: flex;
8138
+ align-items: center;
8139
+ gap: 5px;
7843
8140
  }
7844
-
7845
- .tab:hover {
7846
- color: #667eea;
8141
+ .tab-btn:hover { color: var(--text-2); }
8142
+ .tab-btn.active {
8143
+ background: var(--panel);
8144
+ color: var(--text-1);
8145
+ border: 1px solid var(--border-hi);
7847
8146
  }
7848
-
7849
- .category-section {
7850
- display: none;
8147
+ .tab-btn .tab-dot {
8148
+ width: 6px; height: 6px;
8149
+ border-radius: 50%;
8150
+ background: var(--text-3);
7851
8151
  }
8152
+ .tab-btn.has-required .tab-dot { background: var(--amber); }
8153
+ .tab-btn.active .tab-dot { background: var(--accent); }
7852
8154
 
7853
- .category-section.active {
7854
- display: block;
7855
- }
8155
+ /* \u2500\u2500 Section \u2500\u2500 */
8156
+ .section { display: none; flex-direction: column; gap: 16px; }
8157
+ .section.active { display: flex; }
7856
8158
 
7857
- .category-header {
8159
+ .section-header {
7858
8160
  display: flex;
7859
- justify-content: space-between;
7860
8161
  align-items: center;
7861
- margin-bottom: 20px;
7862
- }
7863
-
7864
- .category-title {
7865
- font-size: 18px;
7866
- font-weight: 600;
7867
- color: #2d3748;
7868
- }
7869
-
7870
- .env-grid {
7871
- display: grid;
7872
- gap: 20px;
8162
+ gap: 12px;
8163
+ margin-bottom: 4px;
7873
8164
  }
7874
-
7875
- .env-item {
7876
- border: 1px solid #e2e8f0;
8165
+ .section-icon {
8166
+ width: 36px; height: 36px;
8167
+ background: var(--accent-lo);
8168
+ border: 1px solid var(--accent-md);
7877
8169
  border-radius: 8px;
7878
- padding: 20px;
7879
- transition: all 0.2s;
8170
+ display: grid;
8171
+ place-items: center;
8172
+ font-size: 16px;
7880
8173
  }
8174
+ .section-title { font-size: 15px; font-weight: 700; }
8175
+ .section-desc { font-family: var(--mono); font-size: 11px; color: var(--text-3); margin-top: 2px; }
7881
8176
 
7882
- .env-item:hover {
7883
- border-color: #cbd5e0;
7884
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
8177
+ /* \u2500\u2500 Env card \u2500\u2500 */
8178
+ .env-card {
8179
+ background: var(--panel);
8180
+ border: 1px solid var(--border);
8181
+ border-radius: var(--radius-lg);
8182
+ padding: 20px 24px;
8183
+ transition: border-color 0.15s;
7885
8184
  }
8185
+ .env-card:hover { border-color: var(--border-hi); }
8186
+ .env-card.has-value { border-color: rgba(249,115,22,0.15); }
7886
8187
 
7887
- .env-item-header {
8188
+ .env-card-head {
7888
8189
  display: flex;
7889
8190
  justify-content: space-between;
7890
8191
  align-items: flex-start;
7891
- margin-bottom: 10px;
8192
+ margin-bottom: 6px;
7892
8193
  }
7893
8194
 
7894
- .env-label {
7895
- font-weight: 600;
7896
- color: #2d3748;
7897
- font-size: 14px;
8195
+ .env-key {
8196
+ font-family: var(--mono);
8197
+ font-size: 13px;
8198
+ font-weight: 500;
8199
+ color: var(--text-1);
7898
8200
  display: flex;
7899
8201
  align-items: center;
7900
8202
  gap: 8px;
7901
8203
  }
7902
8204
 
7903
- .required-badge {
7904
- background: #fc8181;
7905
- color: white;
7906
- font-size: 10px;
7907
- padding: 2px 6px;
8205
+ .badge-required {
8206
+ font-family: var(--sans);
8207
+ font-size: 9px;
8208
+ font-weight: 700;
8209
+ letter-spacing: 0.08em;
8210
+ text-transform: uppercase;
8211
+ background: rgba(239,68,68,0.15);
8212
+ color: var(--red);
8213
+ border: 1px solid rgba(239,68,68,0.25);
7908
8214
  border-radius: 4px;
8215
+ padding: 2px 6px;
8216
+ }
8217
+ .badge-sensitive {
8218
+ font-size: 9px;
7909
8219
  font-weight: 700;
8220
+ font-family: var(--sans);
8221
+ letter-spacing: 0.08em;
8222
+ text-transform: uppercase;
8223
+ background: var(--amber-lo);
8224
+ color: var(--amber);
8225
+ border: 1px solid rgba(245,158,11,0.2);
8226
+ border-radius: 4px;
8227
+ padding: 2px 6px;
7910
8228
  }
7911
8229
 
7912
- .env-description {
7913
- font-size: 13px;
7914
- color: #718096;
7915
- margin-bottom: 10px;
8230
+ .env-desc {
8231
+ font-family: var(--mono);
8232
+ font-size: 11px;
8233
+ color: var(--text-3);
8234
+ margin-bottom: 14px;
8235
+ line-height: 1.5;
7916
8236
  }
7917
8237
 
7918
- .env-input-group {
8238
+ .env-input-row {
7919
8239
  display: flex;
7920
- gap: 10px;
8240
+ gap: 8px;
7921
8241
  align-items: center;
7922
8242
  }
7923
8243
 
7924
- .env-input {
8244
+ .env-input, .env-select {
7925
8245
  flex: 1;
7926
- padding: 10px 12px;
7927
- border: 1px solid #e2e8f0;
7928
- border-radius: 6px;
7929
- font-size: 14px;
7930
- font-family: 'Monaco', 'Courier New', monospace;
7931
- transition: all 0.2s;
7932
- }
7933
-
7934
- .env-input:focus {
8246
+ background: var(--surface);
8247
+ border: 1px solid var(--border-hi);
8248
+ border-radius: 8px;
8249
+ padding: 10px 14px;
8250
+ font-family: var(--mono);
8251
+ font-size: 13px;
8252
+ color: var(--text-1);
7935
8253
  outline: none;
7936
- border-color: #667eea;
7937
- box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
8254
+ transition: border-color 0.15s, box-shadow 0.15s;
8255
+ appearance: none;
7938
8256
  }
7939
-
7940
- .env-input.error {
7941
- border-color: #fc8181;
8257
+ .env-input:focus, .env-select:focus {
8258
+ border-color: var(--accent);
8259
+ box-shadow: 0 0 0 3px rgba(249,115,22,0.12);
7942
8260
  }
8261
+ .env-input.changed { border-color: rgba(249,115,22,0.5); }
8262
+ .env-input.invalid { border-color: var(--red); }
7943
8263
 
7944
- .env-actions {
7945
- display: flex;
7946
- gap: 5px;
7947
- }
8264
+ .env-select option { background: var(--panel); }
7948
8265
 
7949
- .icon-btn {
7950
- padding: 8px;
7951
- border: none;
7952
- background: #e2e8f0;
7953
- border-radius: 6px;
8266
+ .env-action-btn {
8267
+ width: 36px; height: 36px;
8268
+ border-radius: 8px;
8269
+ border: 1px solid var(--border-hi);
8270
+ background: var(--surface);
8271
+ color: var(--text-2);
7954
8272
  cursor: pointer;
7955
- transition: all 0.2s;
7956
- font-size: 16px;
7957
- }
7958
-
7959
- .icon-btn:hover {
7960
- background: #cbd5e0;
7961
- }
7962
-
7963
- .icon-btn.test {
7964
- background: #bee3f8;
7965
- color: #2c5282;
7966
- }
7967
-
7968
- .icon-btn.test:hover {
7969
- background: #90cdf4;
7970
- }
7971
-
7972
- .icon-btn.generate {
7973
- background: #c6f6d5;
7974
- color: #22543d;
7975
- }
7976
-
7977
- .icon-btn.generate:hover {
7978
- background: #9ae6b4;
8273
+ display: grid;
8274
+ place-items: center;
8275
+ font-size: 14px;
8276
+ transition: all 0.15s;
8277
+ flex-shrink: 0;
7979
8278
  }
8279
+ .env-action-btn:hover { background: var(--panel); color: var(--text-1); border-color: var(--border-hi); }
8280
+ .env-action-btn.test-btn:hover { background: var(--blue-lo); color: var(--blue); border-color: rgba(56,189,248,0.25); }
8281
+ .env-action-btn.gen-btn:hover { background: var(--green-lo); color: var(--green); border-color: rgba(34,197,94,0.25); }
7980
8282
 
7981
- .error-message {
7982
- color: #e53e3e;
7983
- font-size: 12px;
7984
- margin-top: 5px;
8283
+ .env-feedback {
8284
+ font-family: var(--mono);
8285
+ font-size: 11px;
8286
+ margin-top: 8px;
8287
+ min-height: 16px;
7985
8288
  }
8289
+ .env-feedback.error { color: var(--red); }
8290
+ .env-feedback.success { color: var(--green); }
7986
8291
 
7987
- .success-message {
7988
- color: #38a169;
7989
- font-size: 12px;
7990
- margin-top: 5px;
8292
+ /* \u2500\u2500 Toast \u2500\u2500 */
8293
+ .toast-zone {
8294
+ position: fixed;
8295
+ bottom: 24px;
8296
+ right: 24px;
8297
+ display: flex;
8298
+ flex-direction: column;
8299
+ gap: 8px;
8300
+ z-index: 100;
7991
8301
  }
7992
8302
 
7993
- .alert {
7994
- padding: 15px 20px;
7995
- border-radius: 8px;
7996
- margin-bottom: 20px;
8303
+ .toast {
8304
+ padding: 12px 18px;
8305
+ border-radius: 10px;
8306
+ font-size: 13px;
8307
+ font-weight: 600;
7997
8308
  display: flex;
7998
8309
  align-items: center;
7999
8310
  gap: 10px;
8311
+ animation: slideIn 0.2s ease;
8312
+ max-width: 380px;
8000
8313
  }
8314
+ .toast.success { background: var(--panel); border: 1px solid rgba(34,197,94,0.3); color: var(--green); }
8315
+ .toast.error { background: var(--panel); border: 1px solid rgba(239,68,68,0.3); color: var(--red); }
8316
+ .toast.warning { background: var(--panel); border: 1px solid rgba(245,158,11,0.3); color: var(--amber); }
8317
+ .toast.info { background: var(--panel); border: 1px solid rgba(56,189,248,0.3); color: var(--blue); }
8001
8318
 
8002
- .alert-warning {
8003
- background: #fef5e7;
8004
- border-left: 4px solid #f59e0b;
8005
- color: #92400e;
8006
- }
8007
-
8008
- .alert-info {
8009
- background: #eff6ff;
8010
- border-left: 4px solid #3b82f6;
8011
- color: #1e40af;
8319
+ @keyframes slideIn {
8320
+ from { opacity: 0; transform: translateY(8px); }
8321
+ to { opacity: 1; transform: translateY(0); }
8012
8322
  }
8013
8323
 
8014
- .alert-success {
8015
- background: #f0fdf4;
8016
- border-left: 4px solid #10b981;
8017
- color: #065f46;
8324
+ /* \u2500\u2500 Modal (test result) \u2500\u2500 */
8325
+ .modal-backdrop {
8326
+ display: none;
8327
+ position: fixed; inset: 0;
8328
+ background: rgba(0,0,0,0.6);
8329
+ z-index: 200;
8330
+ align-items: center;
8331
+ justify-content: center;
8018
8332
  }
8333
+ .modal-backdrop.open { display: flex; }
8019
8334
 
8020
- .loading {
8021
- text-align: center;
8022
- padding: 40px;
8023
- color: #718096;
8335
+ .modal {
8336
+ background: var(--surface);
8337
+ border: 1px solid var(--border-hi);
8338
+ border-radius: var(--radius-lg);
8339
+ padding: 28px;
8340
+ width: 420px;
8341
+ max-width: 90vw;
8342
+ box-shadow: 0 24px 64px rgba(0,0,0,0.5);
8343
+ }
8344
+ .modal-title { font-size: 15px; font-weight: 700; margin-bottom: 16px; }
8345
+ .modal-body { margin-bottom: 20px; }
8346
+ .modal-result {
8347
+ padding: 14px;
8348
+ border-radius: 8px;
8349
+ font-family: var(--mono);
8350
+ font-size: 12px;
8024
8351
  }
8352
+ .modal-result.ok { background: var(--green-lo); border: 1px solid rgba(34,197,94,0.2); color: var(--green); }
8353
+ .modal-result.fail { background: var(--red-lo); border: 1px solid rgba(239,68,68,0.2); color: var(--red); }
8354
+ .modal-footer { display: flex; justify-content: flex-end; }
8025
8355
 
8356
+ /* \u2500\u2500 Spinner \u2500\u2500 */
8026
8357
  .spinner {
8027
- border: 3px solid #e2e8f0;
8028
- border-top: 3px solid #667eea;
8358
+ width: 36px; height: 36px;
8359
+ border: 3px solid var(--border);
8360
+ border-top-color: var(--accent);
8029
8361
  border-radius: 50%;
8030
- width: 40px;
8031
- height: 40px;
8032
- animation: spin 1s linear infinite;
8033
- margin: 0 auto 20px;
8362
+ animation: spin 0.8s linear infinite;
8363
+ margin: 0 auto 16px;
8034
8364
  }
8365
+ @keyframes spin { to { transform: rotate(360deg); } }
8035
8366
 
8036
- @keyframes spin {
8037
- 0% { transform: rotate(0deg); }
8038
- 100% { transform: rotate(360deg); }
8367
+ .loading-state {
8368
+ text-align: center;
8369
+ padding: 60px 0;
8370
+ color: var(--text-3);
8371
+ font-family: var(--mono);
8372
+ font-size: 12px;
8039
8373
  }
8040
8374
 
8041
- .modal {
8375
+ /* \u2500\u2500 Restart banner \u2500\u2500 */
8376
+ .restart-banner {
8042
8377
  display: none;
8043
- position: fixed;
8044
- top: 0;
8045
- left: 0;
8046
- right: 0;
8047
- bottom: 0;
8048
- background: rgba(0, 0, 0, 0.5);
8049
- z-index: 1000;
8378
+ background: var(--amber-lo);
8379
+ border: 1px solid rgba(245,158,11,0.25);
8380
+ border-radius: 10px;
8381
+ padding: 12px 18px;
8382
+ font-size: 13px;
8383
+ color: var(--amber);
8384
+ font-weight: 600;
8050
8385
  align-items: center;
8051
- justify-content: center;
8386
+ gap: 10px;
8052
8387
  }
8388
+ .restart-banner.show { display: flex; }
8389
+ </style>
8390
+ </head>
8391
+ <body>
8392
+ <div class="shell">
8053
8393
 
8054
- .modal.show {
8055
- display: flex;
8056
- }
8394
+ <!-- Sidebar -->
8395
+ <aside>
8396
+ <div class="logo">
8397
+ <div class="logo-mark">Q</div>
8398
+ <div>
8399
+ <div class="logo-name">OpenQA</div>
8400
+ <div class="logo-version">v1.3.4</div>
8401
+ </div>
8402
+ </div>
8057
8403
 
8058
- .modal-content {
8059
- background: white;
8060
- padding: 30px;
8061
- border-radius: 12px;
8062
- max-width: 500px;
8063
- width: 90%;
8064
- box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
8065
- }
8404
+ <div class="nav-section">
8405
+ <div class="nav-label">Overview</div>
8406
+ <a class="nav-item" href="/">
8407
+ <span class="icon">\u{1F4CA}</span> Dashboard
8408
+ </a>
8409
+ <a class="nav-item" href="/sessions">
8410
+ <span class="icon">\u{1F9EA}</span> Sessions
8411
+ </a>
8412
+ <a class="nav-item" href="/issues">
8413
+ <span class="icon">\u{1F41B}</span> Issues
8414
+ </a>
8066
8415
 
8067
- .modal-header {
8068
- font-size: 20px;
8069
- font-weight: 600;
8070
- margin-bottom: 15px;
8071
- color: #2d3748;
8072
- }
8416
+ <div class="nav-label">Testing</div>
8417
+ <a class="nav-item" href="/tests">
8418
+ <span class="icon">\u26A1</span> Tests
8419
+ </a>
8420
+ <a class="nav-item" href="/coverage">
8421
+ <span class="icon">\u{1F4C8}</span> Coverage
8422
+ </a>
8423
+ <a class="nav-item" href="/kanban">
8424
+ <span class="icon">\u{1F4CB}</span> Kanban
8425
+ </a>
8073
8426
 
8074
- .modal-body {
8075
- margin-bottom: 20px;
8076
- color: #4a5568;
8077
- }
8427
+ <div class="nav-label">System</div>
8428
+ <a class="nav-item" href="/config">
8429
+ <span class="icon">\u2699\uFE0F</span> Config
8430
+ </a>
8431
+ <a class="nav-item active" href="/config/env">
8432
+ <span class="icon">\u{1F527}</span> Environment
8433
+ </a>
8434
+ <a class="nav-item" href="/logs">
8435
+ <span class="icon">\u{1F4DC}</span> Logs
8436
+ </a>
8437
+ </div>
8078
8438
 
8079
- .modal-footer {
8080
- display: flex;
8081
- gap: 10px;
8082
- justify-content: flex-end;
8083
- }
8084
- </style>
8085
- </head>
8086
- <body>
8087
- <div class="container">
8088
- <div class="header">
8089
- <h1>
8090
- <span>\u2699\uFE0F</span>
8439
+ <div class="sidebar-footer">
8440
+ <div style="font-family:var(--mono);font-size:11px;color:var(--text-3);">
8091
8441
  Environment Variables
8092
- </h1>
8093
- <div class="header-actions">
8094
- <a href="/config" class="btn btn-secondary">\u2190 Back to Config</a>
8095
- <button id="saveBtn" class="btn btn-success" disabled>\u{1F4BE} Save Changes</button>
8442
+ </div>
8443
+ </div>
8444
+ </aside>
8445
+
8446
+ <!-- Main -->
8447
+ <main>
8448
+ <div class="topbar">
8449
+ <div>
8450
+ <div class="page-title">Environment Variables</div>
8451
+ <div class="page-sub">Configure runtime variables for OpenQA</div>
8452
+ </div>
8453
+ <div class="topbar-actions">
8454
+ <a class="btn btn-ghost" href="/config">\u2190 Back to Config</a>
8455
+ <button id="saveBtn" class="btn btn-primary" disabled>
8456
+ \u{1F4BE} Save Changes
8457
+ </button>
8096
8458
  </div>
8097
8459
  </div>
8098
8460
 
8099
8461
  <div class="content">
8100
- <div id="loading" class="loading">
8462
+
8463
+ <!-- Restart banner -->
8464
+ <div class="restart-banner" id="restartBanner">
8465
+ \u26A0\uFE0F Some changes require a server restart to take effect.
8466
+ </div>
8467
+
8468
+ <!-- Loading -->
8469
+ <div class="loading-state" id="loadingState">
8101
8470
  <div class="spinner"></div>
8102
- <div>Loading environment variables...</div>
8471
+ Loading environment variables\u2026
8103
8472
  </div>
8104
8473
 
8105
- <div id="main" style="display: none;">
8106
- <div id="alerts"></div>
8107
-
8108
- <div class="tabs">
8109
- <button class="tab active" data-category="llm">\u{1F916} LLM</button>
8110
- <button class="tab" data-category="security">\u{1F512} Security</button>
8111
- <button class="tab" data-category="target">\u{1F3AF} Target App</button>
8112
- <button class="tab" data-category="github">\u{1F419} GitHub</button>
8113
- <button class="tab" data-category="web">\u{1F310} Web Server</button>
8114
- <button class="tab" data-category="agent">\u{1F916} Agent</button>
8115
- <button class="tab" data-category="database">\u{1F4BE} Database</button>
8116
- <button class="tab" data-category="notifications">\u{1F514} Notifications</button>
8117
- </div>
8474
+ <!-- Main content (hidden while loading) -->
8475
+ <div id="mainContent" style="display:none;flex-direction:column;gap:24px;">
8476
+
8477
+ <!-- Tab bar -->
8478
+ <div class="tab-bar" id="tabBar"></div>
8479
+
8480
+ <!-- Sections -->
8481
+ <div id="sections"></div>
8118
8482
 
8119
- <div id="categories"></div>
8120
8483
  </div>
8121
8484
  </div>
8122
- </div>
8485
+ </main>
8486
+ </div>
8123
8487
 
8124
- <!-- Test Result Modal -->
8125
- <div id="testModal" class="modal">
8126
- <div class="modal-content">
8127
- <div class="modal-header">Test Result</div>
8128
- <div class="modal-body" id="testResult"></div>
8129
- <div class="modal-footer">
8130
- <button class="btn btn-secondary" onclick="closeTestModal()">Close</button>
8131
- </div>
8488
+ <!-- Test result modal -->
8489
+ <div class="modal-backdrop" id="testModal">
8490
+ <div class="modal">
8491
+ <div class="modal-title">Connection Test</div>
8492
+ <div class="modal-body">
8493
+ <div class="modal-result" id="testResultBox">\u2026</div>
8494
+ </div>
8495
+ <div class="modal-footer">
8496
+ <button class="btn btn-ghost" onclick="closeModal()">Close</button>
8132
8497
  </div>
8133
8498
  </div>
8499
+ </div>
8134
8500
 
8135
- <script>
8136
- let envVariables = [];
8137
- let changedVariables = {};
8138
- let restartRequired = false;
8501
+ <!-- Toast zone -->
8502
+ <div class="toast-zone" id="toastZone"></div>
8139
8503
 
8140
- // Load environment variables
8141
- async function loadEnvVariables() {
8142
- try {
8143
- const response = await fetch('/api/env');
8144
- if (!response.ok) throw new Error('Failed to load variables');
8145
-
8146
- const data = await response.json();
8147
- envVariables = data.variables;
8148
-
8149
- renderCategories();
8150
- document.getElementById('loading').style.display = 'none';
8151
- document.getElementById('main').style.display = 'block';
8152
- } catch (error) {
8153
- showAlert('error', 'Failed to load environment variables: ' + error.message);
8154
- }
8155
- }
8504
+ <script>
8505
+ /* \u2500\u2500 State \u2500\u2500 */
8506
+ let envVars = [];
8507
+ let changed = {};
8508
+ let hasRequiredMissing = false;
8509
+
8510
+ const TABS = [
8511
+ { id: 'llm', label: '\u{1F916} LLM', desc: 'Language model provider & API keys' },
8512
+ { id: 'security', label: '\u{1F512} Security', desc: 'Authentication & JWT configuration' },
8513
+ { id: 'target', label: '\u{1F3AF} Target App', desc: 'Application under test settings' },
8514
+ { id: 'github', label: '\u{1F419} GitHub', desc: 'Repository & CI/CD integration' },
8515
+ { id: 'web', label: '\u{1F310} Web Server', desc: 'HTTP host, port & CORS settings' },
8516
+ { id: 'agent', label: '\u{1F916} Agent', desc: 'Autonomous agent behaviour' },
8517
+ { id: 'database', label: '\u{1F4BE} Database', desc: 'Persistence & storage' },
8518
+ { id: 'notifications', label: '\u{1F514} Notifications', desc: 'Slack & Discord webhooks' },
8519
+ ];
8156
8520
 
8157
- // Render categories
8158
- function renderCategories() {
8159
- const container = document.getElementById('categories');
8160
- const categories = [...new Set(envVariables.map(v => v.category))];
8161
-
8162
- categories.forEach((category, index) => {
8163
- const section = document.createElement('div');
8164
- section.className = 'category-section' + (index === 0 ? ' active' : '');
8165
- section.dataset.category = category;
8166
-
8167
- const vars = envVariables.filter(v => v.category === category);
8168
-
8169
- section.innerHTML = \`
8170
- <div class="category-header">
8171
- <div class="category-title">\${getCategoryTitle(category)}</div>
8172
- </div>
8173
- <div class="env-grid">
8174
- \${vars.map(v => renderEnvItem(v)).join('')}
8175
- </div>
8176
- \`;
8177
-
8178
- container.appendChild(section);
8179
- });
8180
- }
8521
+ /* \u2500\u2500 Init \u2500\u2500 */
8522
+ async function init() {
8523
+ try {
8524
+ const res = await fetch('/api/env');
8525
+ if (!res.ok) { toast('error', 'Failed to load environment variables (status ' + res.status + ')'); return; }
8526
+ const data = await res.json();
8527
+ envVars = data.variables || [];
8528
+ renderAll();
8529
+ document.getElementById('loadingState').style.display = 'none';
8530
+ const mc = document.getElementById('mainContent');
8531
+ mc.style.display = 'flex';
8532
+ } catch (e) {
8533
+ toast('error', 'Network error \u2014 ' + e.message);
8534
+ }
8535
+ }
8181
8536
 
8182
- // Render single env item
8183
- function renderEnvItem(envVar) {
8184
- const inputType = envVar.type === 'password' ? 'password' : 'text';
8185
- const value = envVar.displayValue || '';
8186
-
8187
- return \`
8188
- <div class="env-item" data-key="\${envVar.key}">
8189
- <div class="env-item-header">
8190
- <div class="env-label">
8191
- \${envVar.key}
8192
- \${envVar.required ? '<span class="required-badge">REQUIRED</span>' : ''}
8193
- </div>
8194
- </div>
8195
- <div class="env-description">\${envVar.description}</div>
8196
- <div class="env-input-group">
8197
- \${envVar.type === 'select' ?
8198
- \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
8199
- <option value="">-- Select --</option>
8200
- \${envVar.options.map(opt =>
8201
- \`<option value="\${opt}" \${value === opt ? 'selected' : ''}>\${opt}</option>\`
8202
- ).join('')}
8203
- </select>\` :
8204
- envVar.type === 'boolean' ?
8205
- \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
8206
- <option value="">-- Select --</option>
8207
- <option value="true" \${value === 'true' ? 'selected' : ''}>true</option>
8208
- <option value="false" \${value === 'false' ? 'selected' : ''}>false</option>
8209
- </select>\` :
8210
- \`<input
8211
- type="\${inputType}"
8212
- class="env-input"
8213
- data-key="\${envVar.key}"
8214
- value="\${value}"
8215
- placeholder="\${envVar.placeholder || ''}"
8216
- onchange="handleChange(this)"
8217
- />\`
8218
- }
8219
- <div class="env-actions">
8220
- \${envVar.testable ? \`<button class="icon-btn test" onclick="testVariable('\${envVar.key}')" title="Test">\u{1F9EA}</button>\` : ''}
8221
- \${envVar.key === 'OPENQA_JWT_SECRET' ? \`<button class="icon-btn generate" onclick="generateSecret('\${envVar.key}')" title="Generate">\u{1F511}</button>\` : ''}
8222
- </div>
8223
- </div>
8224
- <div class="error-message" id="error-\${envVar.key}"></div>
8225
- <div class="success-message" id="success-\${envVar.key}"></div>
8537
+ /* \u2500\u2500 Render \u2500\u2500 */
8538
+ function renderAll() {
8539
+ renderTabBar();
8540
+ renderSections();
8541
+ activateTab(TABS[0].id);
8542
+ }
8543
+
8544
+ function renderTabBar() {
8545
+ const bar = document.getElementById('tabBar');
8546
+ bar.innerHTML = TABS.map(t => {
8547
+ const vars = envVars.filter(v => v.category === t.id);
8548
+ const hasRequired = vars.some(v => v.required);
8549
+ return \`<button class="tab-btn\${hasRequired ? ' has-required' : ''}" data-tab="\${t.id}" onclick="activateTab('\${t.id}')">
8550
+ <span class="tab-dot"></span>
8551
+ \${t.label}
8552
+ </button>\`;
8553
+ }).join('');
8554
+ }
8555
+
8556
+ function renderSections() {
8557
+ const container = document.getElementById('sections');
8558
+ container.innerHTML = TABS.map(t => {
8559
+ const vars = envVars.filter(v => v.category === t.id);
8560
+ return \`<div class="section" id="section-\${t.id}">
8561
+ <div class="section-header">
8562
+ <div class="section-icon">\${t.label.split(' ')[0]}</div>
8563
+ <div>
8564
+ <div class="section-title">\${t.label.slice(t.label.indexOf(' ')+1)}</div>
8565
+ <div class="section-desc">\${t.desc}</div>
8226
8566
  </div>
8227
- \`;
8228
- }
8567
+ </div>
8568
+ \${vars.map(renderCard).join('')}
8569
+ \${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>' : ''}
8570
+ </div>\`;
8571
+ }).join('');
8572
+ }
8229
8573
 
8230
- // Handle input change
8231
- function handleChange(input) {
8232
- const key = input.dataset.key;
8233
- const value = input.value;
8234
-
8235
- changedVariables[key] = value;
8236
- document.getElementById('saveBtn').disabled = false;
8237
-
8238
- // Clear messages
8239
- document.getElementById(\`error-\${key}\`).textContent = '';
8240
- document.getElementById(\`success-\${key}\`).textContent = '';
8241
- }
8574
+ function renderCard(v) {
8575
+ const displayVal = v.displayValue || '';
8576
+ const isSensitive = v.sensitive;
8577
+ const inputType = (v.type === 'password' && !changed[v.key]) ? 'password' : 'text';
8578
+
8579
+ let inputHTML = '';
8580
+ if (v.type === 'select' || v.type === 'boolean') {
8581
+ const opts = v.type === 'boolean'
8582
+ ? [{ val: 'true', lbl: 'true' }, { val: 'false', lbl: 'false' }]
8583
+ : (v.options || []).map(o => ({ val: o, lbl: o }));
8584
+ inputHTML = \`<select class="env-select" data-key="\${v.key}" onchange="handleChange(this)">
8585
+ <option value="">\u2014 Select \u2014</option>
8586
+ \${opts.map(o => \`<option value="\${o.val}" \${displayVal === o.val ? 'selected' : ''}>\${o.lbl}</option>\`).join('')}
8587
+ </select>\`;
8588
+ } else {
8589
+ inputHTML = \`<input
8590
+ type="\${inputType}"
8591
+ class="env-input"
8592
+ data-key="\${v.key}"
8593
+ value="\${escHtml(displayVal)}"
8594
+ placeholder="\${escHtml(v.placeholder || '')}"
8595
+ oninput="handleChange(this)"
8596
+ autocomplete="off"
8597
+ />\`;
8598
+ }
8599
+
8600
+ const testBtn = v.testable
8601
+ ? \`<button class="env-action-btn test-btn" onclick="testVar('\${v.key}')" title="Test connection">\u{1F9EA}</button>\`
8602
+ : '';
8603
+
8604
+ const genBtn = v.key === 'OPENQA_JWT_SECRET'
8605
+ ? \`<button class="env-action-btn gen-btn" onclick="generateSecret('\${v.key}')" title="Generate secret">\u{1F511}</button>\`
8606
+ : '';
8607
+
8608
+ const toggleBtn = (v.type === 'password' || isSensitive)
8609
+ ? \`<button class="env-action-btn" onclick="toggleVis('\${v.key}')" title="Toggle visibility" id="vis-\${v.key}">\u{1F441}</button>\`
8610
+ : '';
8611
+
8612
+ return \`<div class="env-card\${displayVal ? ' has-value' : ''}" id="card-\${v.key}">
8613
+ <div class="env-card-head">
8614
+ <div class="env-key">
8615
+ \${v.key}
8616
+ \${v.required ? '<span class="badge-required">Required</span>' : ''}
8617
+ \${isSensitive ? '<span class="badge-sensitive">Sensitive</span>' : ''}
8618
+ </div>
8619
+ </div>
8620
+ <div class="env-desc">\${v.description}</div>
8621
+ <div class="env-input-row">
8622
+ \${inputHTML}
8623
+ \${toggleBtn}
8624
+ \${testBtn}
8625
+ \${genBtn}
8626
+ </div>
8627
+ <div class="env-feedback" id="fb-\${v.key}"></div>
8628
+ </div>\`;
8629
+ }
8242
8630
 
8243
- // Save changes
8244
- async function saveChanges() {
8245
- const saveBtn = document.getElementById('saveBtn');
8246
- saveBtn.disabled = true;
8247
- saveBtn.textContent = '\u{1F4BE} Saving...';
8248
-
8249
- try {
8250
- const response = await fetch('/api/env/bulk', {
8251
- method: 'POST',
8252
- headers: { 'Content-Type': 'application/json' },
8253
- body: JSON.stringify({ variables: changedVariables }),
8254
- });
8255
-
8256
- if (!response.ok) {
8257
- const error = await response.json();
8258
- throw new Error(error.error || 'Failed to save');
8631
+ /* \u2500\u2500 Tab switching \u2500\u2500 */
8632
+ function activateTab(id) {
8633
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === id));
8634
+ document.querySelectorAll('.section').forEach(s => s.classList.toggle('active', s.id === 'section-' + id));
8635
+ }
8636
+
8637
+ /* \u2500\u2500 Input handling \u2500\u2500 */
8638
+ function handleChange(el) {
8639
+ const key = el.dataset.key;
8640
+ const val = el.value;
8641
+ changed[key] = val;
8642
+ el.classList.add('changed');
8643
+ el.classList.remove('invalid');
8644
+ clearFeedback(key);
8645
+ document.getElementById('saveBtn').disabled = false;
8646
+ }
8647
+
8648
+ /* \u2500\u2500 Toggle password visibility \u2500\u2500 */
8649
+ function toggleVis(key) {
8650
+ const inp = document.querySelector('[data-key="' + key + '"]');
8651
+ if (!inp || inp.tagName !== 'INPUT') return;
8652
+ inp.type = inp.type === 'password' ? 'text' : 'password';
8653
+ }
8654
+
8655
+ /* \u2500\u2500 Save \u2500\u2500 */
8656
+ async function saveChanges() {
8657
+ if (!Object.keys(changed).length) return;
8658
+
8659
+ const btn = document.getElementById('saveBtn');
8660
+ btn.disabled = true;
8661
+ btn.textContent = '\u23F3 Saving\u2026';
8662
+
8663
+ try {
8664
+ const res = await fetch('/api/env/bulk', {
8665
+ method: 'POST',
8666
+ headers: { 'Content-Type': 'application/json' },
8667
+ body: JSON.stringify({ variables: changed }),
8668
+ credentials: 'include',
8669
+ });
8670
+
8671
+ const body = await res.json().catch(() => ({}));
8672
+
8673
+ if (!res.ok) {
8674
+ const errStr = body.errors
8675
+ ? Object.entries(body.errors).map(([k, v]) => k + ': ' + v).join('; ')
8676
+ : body.error || 'Failed to save';
8677
+ // Show per-field errors
8678
+ if (body.errors) {
8679
+ for (const [k, msg] of Object.entries(body.errors)) {
8680
+ setFeedback(k, 'error', msg);
8681
+ const inp = document.querySelector('[data-key="' + k + '"]');
8682
+ if (inp) inp.classList.add('invalid');
8259
8683
  }
8260
-
8261
- const result = await response.json();
8262
- restartRequired = result.restartRequired;
8263
-
8264
- showAlert('success', \`\u2705 Saved \${result.updated} variable(s) successfully!\` +
8265
- (restartRequired ? ' \u26A0\uFE0F Restart required for changes to take effect.' : ''));
8266
-
8267
- changedVariables = {};
8268
- saveBtn.textContent = '\u{1F4BE} Save Changes';
8269
-
8270
- // Reload to show updated values
8271
- setTimeout(() => location.reload(), 2000);
8272
- } catch (error) {
8273
- showAlert('error', 'Failed to save: ' + error.message);
8274
- saveBtn.disabled = false;
8275
- saveBtn.textContent = '\u{1F4BE} Save Changes';
8276
8684
  }
8685
+ toast('error', errStr);
8686
+ btn.disabled = false;
8687
+ btn.innerHTML = '\u{1F4BE} Save Changes';
8688
+ return;
8277
8689
  }
8278
8690
 
8279
- // Test variable
8280
- async function testVariable(key) {
8281
- const input = document.querySelector(\`[data-key="\${key}"]\`);
8282
- const value = input.value;
8283
-
8284
- if (!value) {
8285
- showAlert('warning', 'Please enter a value first');
8286
- return;
8287
- }
8288
-
8289
- try {
8290
- const response = await fetch(\`/api/env/test/\${key}\`, {
8291
- method: 'POST',
8292
- headers: { 'Content-Type': 'application/json' },
8293
- body: JSON.stringify({ value }),
8294
- });
8295
-
8296
- const result = await response.json();
8297
- showTestResult(result);
8298
- } catch (error) {
8299
- showTestResult({ success: false, message: 'Test failed: ' + error.message });
8300
- }
8691
+ toast('success', '\u2705 Saved ' + body.updated + ' variable(s)');
8692
+ if (body.restartRequired) {
8693
+ document.getElementById('restartBanner').classList.add('show');
8301
8694
  }
8302
8695
 
8303
- // Generate secret
8304
- async function generateSecret(key) {
8305
- try {
8306
- const response = await fetch(\`/api/env/generate/\${key}\`, {
8307
- method: 'POST',
8308
- });
8309
-
8310
- if (!response.ok) throw new Error('Failed to generate');
8311
-
8312
- const result = await response.json();
8313
- const input = document.querySelector(\`[data-key="\${key}"]\`);
8314
- input.value = result.value;
8315
- handleChange(input);
8316
-
8317
- document.getElementById(\`success-\${key}\`).textContent = '\u2705 Secret generated!';
8318
- } catch (error) {
8319
- document.getElementById(\`error-\${key}\`).textContent = 'Failed to generate: ' + error.message;
8320
- }
8321
- }
8696
+ changed = {};
8697
+ btn.innerHTML = '\u{1F4BE} Save Changes';
8698
+ // Reload to reflect masked values
8699
+ setTimeout(() => location.reload(), 1200);
8700
+ } catch (e) {
8701
+ toast('error', 'Network error \u2014 ' + e.message);
8702
+ btn.disabled = false;
8703
+ btn.innerHTML = '\u{1F4BE} Save Changes';
8704
+ }
8705
+ }
8322
8706
 
8323
- // Show test result
8324
- function showTestResult(result) {
8325
- const modal = document.getElementById('testModal');
8326
- const resultDiv = document.getElementById('testResult');
8327
-
8328
- resultDiv.innerHTML = \`
8329
- <div class="alert \${result.success ? 'alert-success' : 'alert-warning'}">
8330
- \${result.success ? '\u2705' : '\u274C'} \${result.message}
8331
- </div>
8332
- \`;
8333
-
8334
- modal.classList.add('show');
8335
- }
8707
+ /* \u2500\u2500 Test variable \u2500\u2500 */
8708
+ async function testVar(key) {
8709
+ const inp = document.querySelector('[data-key="' + key + '"]');
8710
+ const val = inp ? inp.value : '';
8711
+ if (!val) { toast('warning', 'Enter a value first'); return; }
8336
8712
 
8337
- function closeTestModal() {
8338
- document.getElementById('testModal').classList.remove('show');
8339
- }
8713
+ setFeedback(key, '', '');
8714
+ const btn = document.querySelector('[onclick="testVar(\\''+key+'\\')"]');
8715
+ if (btn) { btn.textContent = '\u23F3'; btn.disabled = true; }
8340
8716
 
8341
- // Show alert
8342
- function showAlert(type, message) {
8343
- const alerts = document.getElementById('alerts');
8344
- const alertClass = type === 'error' ? 'alert-warning' :
8345
- type === 'success' ? 'alert-success' : 'alert-info';
8346
-
8347
- alerts.innerHTML = \`
8348
- <div class="alert \${alertClass}">
8349
- \${message}
8350
- </div>
8351
- \`;
8352
-
8353
- setTimeout(() => alerts.innerHTML = '', 5000);
8354
- }
8355
-
8356
- // Get category title
8357
- function getCategoryTitle(category) {
8358
- const titles = {
8359
- llm: '\u{1F916} LLM Configuration',
8360
- security: '\u{1F512} Security Settings',
8361
- target: '\u{1F3AF} Target Application',
8362
- github: '\u{1F419} GitHub Integration',
8363
- web: '\u{1F310} Web Server',
8364
- agent: '\u{1F916} Agent Configuration',
8365
- database: '\u{1F4BE} Database',
8366
- notifications: '\u{1F514} Notifications',
8367
- };
8368
- return titles[category] || category;
8369
- }
8370
-
8371
- // Tab switching
8372
- document.addEventListener('click', (e) => {
8373
- if (e.target.classList.contains('tab')) {
8374
- const category = e.target.dataset.category;
8375
-
8376
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
8377
- e.target.classList.add('active');
8378
-
8379
- document.querySelectorAll('.category-section').forEach(s => s.classList.remove('active'));
8380
- document.querySelector(\`[data-category="\${category}"]\`).classList.add('active');
8381
- }
8717
+ try {
8718
+ const res = await fetch('/api/env/test/' + key, {
8719
+ method: 'POST',
8720
+ headers: { 'Content-Type': 'application/json' },
8721
+ body: JSON.stringify({ value: val }),
8722
+ credentials: 'include',
8382
8723
  });
8724
+ const result = await res.json();
8725
+ openModal(result.success, result.message);
8726
+ setFeedback(key, result.success ? 'success' : 'error', result.success ? '\u2713 Connected' : '\u2717 ' + result.message);
8727
+ } catch (e) {
8728
+ openModal(false, 'Network error: ' + e.message);
8729
+ } finally {
8730
+ if (btn) { btn.textContent = '\u{1F9EA}'; btn.disabled = false; }
8731
+ }
8732
+ }
8383
8733
 
8384
- // Save button
8385
- document.getElementById('saveBtn').addEventListener('click', saveChanges);
8734
+ /* \u2500\u2500 Generate secret \u2500\u2500 */
8735
+ async function generateSecret(key) {
8736
+ try {
8737
+ const res = await fetch('/api/env/generate/' + key, {
8738
+ method: 'POST', credentials: 'include'
8739
+ });
8740
+ if (!res.ok) throw new Error('Failed to generate');
8741
+ const { value } = await res.json();
8742
+ const inp = document.querySelector('[data-key="' + key + '"]');
8743
+ if (inp) {
8744
+ inp.type = 'text';
8745
+ inp.value = value;
8746
+ handleChange(inp);
8747
+ }
8748
+ setFeedback(key, 'success', '\u2713 Secret generated \u2014 save to persist');
8749
+ } catch (e) {
8750
+ setFeedback(key, 'error', e.message);
8751
+ }
8752
+ }
8386
8753
 
8387
- // Load on page load
8388
- loadEnvVariables();
8389
- </script>
8754
+ /* \u2500\u2500 Modal \u2500\u2500 */
8755
+ function openModal(ok, msg) {
8756
+ const box = document.getElementById('testResultBox');
8757
+ box.className = 'modal-result ' + (ok ? 'ok' : 'fail');
8758
+ box.textContent = (ok ? '\u2713 ' : '\u2717 ') + msg;
8759
+ document.getElementById('testModal').classList.add('open');
8760
+ }
8761
+ function closeModal() {
8762
+ document.getElementById('testModal').classList.remove('open');
8763
+ }
8764
+
8765
+ /* \u2500\u2500 Toast \u2500\u2500 */
8766
+ function toast(type, msg) {
8767
+ const zone = document.getElementById('toastZone');
8768
+ const el = document.createElement('div');
8769
+ el.className = 'toast ' + type;
8770
+ el.textContent = msg;
8771
+ zone.appendChild(el);
8772
+ setTimeout(() => el.remove(), 4500);
8773
+ }
8774
+
8775
+ /* \u2500\u2500 Feedback \u2500\u2500 */
8776
+ function setFeedback(key, type, msg) {
8777
+ const el = document.getElementById('fb-' + key);
8778
+ if (!el) return;
8779
+ el.className = 'env-feedback' + (type ? ' ' + type : '');
8780
+ el.textContent = msg;
8781
+ }
8782
+ function clearFeedback(key) { setFeedback(key, '', ''); }
8783
+
8784
+ /* \u2500\u2500 Helpers \u2500\u2500 */
8785
+ function escHtml(s) {
8786
+ return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
8787
+ }
8788
+
8789
+ /* \u2500\u2500 Wire save button \u2500\u2500 */
8790
+ document.getElementById('saveBtn').addEventListener('click', saveChanges);
8791
+
8792
+ /* \u2500\u2500 Close modal on backdrop click \u2500\u2500 */
8793
+ document.getElementById('testModal').addEventListener('click', function(e) {
8794
+ if (e.target === this) closeModal();
8795
+ });
8796
+
8797
+ /* \u2500\u2500 Boot \u2500\u2500 */
8798
+ init();
8799
+ </script>
8390
8800
  </body>
8391
8801
  </html>`;
8392
8802
  }