@openqa/cli 2.1.1 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -1652,7 +1837,7 @@ var BrowserTools = class {
1652
1837
  if (!this.page) return { output: "Browser not initialized. Navigate to a page first.", error: "Browser not initialized" };
1653
1838
  try {
1654
1839
  const filename = `${Date.now()}_${name}.png`;
1655
- const path2 = join4(this.screenshotDir, filename);
1840
+ const path2 = join5(this.screenshotDir, filename);
1656
1841
  await this.page.screenshot({ path: path2, fullPage: true });
1657
1842
  this.db.createAction({
1658
1843
  session_id: this.sessionId,
@@ -1808,6 +1993,16 @@ var GitListener = class extends EventEmitter3 {
1808
1993
  for (const commit of commits) {
1809
1994
  if (this.lastCommitSha && commit.sha === this.lastCommitSha) break;
1810
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
+ }
1811
2006
  const event = {
1812
2007
  type: isMerge ? "merge" : "push",
1813
2008
  provider: "github",
@@ -1815,7 +2010,8 @@ var GitListener = class extends EventEmitter3 {
1815
2010
  commit: commit.sha,
1816
2011
  author: commit.commit.author?.name || "unknown",
1817
2012
  message: commit.commit.message,
1818
- timestamp: new Date(commit.commit.author?.date || Date.now())
2013
+ timestamp: new Date(commit.commit.author?.date || Date.now()),
2014
+ changedFiles
1819
2015
  };
1820
2016
  this.emit("git-event", event);
1821
2017
  if (isMerge) {
@@ -1901,6 +2097,16 @@ var GitListener = class extends EventEmitter3 {
1901
2097
  for (const commit of commits) {
1902
2098
  if (this.lastCommitSha && commit.id === this.lastCommitSha) break;
1903
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
+ }
1904
2110
  const event = {
1905
2111
  type: isMerge ? "merge" : "push",
1906
2112
  provider: "gitlab",
@@ -1908,7 +2114,8 @@ var GitListener = class extends EventEmitter3 {
1908
2114
  commit: commit.id,
1909
2115
  author: commit.author_name,
1910
2116
  message: commit.message,
1911
- timestamp: new Date(commit.created_at)
2117
+ timestamp: new Date(commit.created_at),
2118
+ changedFiles
1912
2119
  };
1913
2120
  this.emit("git-event", event);
1914
2121
  if (isMerge) {
@@ -2003,8 +2210,8 @@ var GitListener = class extends EventEmitter3 {
2003
2210
  init_esm_shims();
2004
2211
  import { EventEmitter as EventEmitter4 } from "events";
2005
2212
  import { spawn } from "child_process";
2006
- import { existsSync as existsSync3, readFileSync as readFileSync4, statSync } from "fs";
2007
- 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";
2008
2215
  function sanitizeRepoPath(inputPath) {
2009
2216
  if (typeof inputPath !== "string" || !inputPath.trim()) {
2010
2217
  throw new ProjectRunnerError("repoPath must be a non-empty string");
@@ -2032,14 +2239,14 @@ var ProjectRunner = class extends EventEmitter4 {
2032
2239
  */
2033
2240
  detectProjectType(repoPath) {
2034
2241
  repoPath = sanitizeRepoPath(repoPath);
2035
- const pkgPath = join5(repoPath, "package.json");
2036
- if (existsSync3(pkgPath)) {
2242
+ const pkgPath = join6(repoPath, "package.json");
2243
+ if (existsSync4(pkgPath)) {
2037
2244
  return this.detectNodeProject(repoPath, pkgPath);
2038
2245
  }
2039
- if (existsSync3(join5(repoPath, "requirements.txt")) || existsSync3(join5(repoPath, "pyproject.toml"))) {
2246
+ if (existsSync4(join6(repoPath, "requirements.txt")) || existsSync4(join6(repoPath, "pyproject.toml"))) {
2040
2247
  return this.detectPythonProject(repoPath);
2041
2248
  }
2042
- if (existsSync3(join5(repoPath, "go.mod"))) {
2249
+ if (existsSync4(join6(repoPath, "go.mod"))) {
2043
2250
  return {
2044
2251
  language: "go",
2045
2252
  packageManager: "go",
@@ -2048,7 +2255,7 @@ var ProjectRunner = class extends EventEmitter4 {
2048
2255
  devCommand: "go run ."
2049
2256
  };
2050
2257
  }
2051
- if (existsSync3(join5(repoPath, "Cargo.toml"))) {
2258
+ if (existsSync4(join6(repoPath, "Cargo.toml"))) {
2052
2259
  return {
2053
2260
  language: "rust",
2054
2261
  packageManager: "cargo",
@@ -2064,9 +2271,9 @@ var ProjectRunner = class extends EventEmitter4 {
2064
2271
  const scripts = pkg.scripts || {};
2065
2272
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
2066
2273
  let packageManager = "npm";
2067
- if (existsSync3(join5(repoPath, "pnpm-lock.yaml"))) packageManager = "pnpm";
2068
- else if (existsSync3(join5(repoPath, "yarn.lock"))) packageManager = "yarn";
2069
- 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";
2070
2277
  let framework;
2071
2278
  if (deps["next"]) framework = "next";
2072
2279
  else if (deps["nuxt"]) framework = "nuxt";
@@ -2118,13 +2325,13 @@ var ProjectRunner = class extends EventEmitter4 {
2118
2325
  }
2119
2326
  detectPythonProject(repoPath) {
2120
2327
  let testRunner;
2121
- const reqPath = join5(repoPath, "requirements.txt");
2122
- if (existsSync3(reqPath)) {
2328
+ const reqPath = join6(repoPath, "requirements.txt");
2329
+ if (existsSync4(reqPath)) {
2123
2330
  const content = readFileSync4(reqPath, "utf-8");
2124
2331
  if (content.includes("pytest")) testRunner = "pytest";
2125
2332
  }
2126
- const pyprojectPath = join5(repoPath, "pyproject.toml");
2127
- if (existsSync3(pyprojectPath)) {
2333
+ const pyprojectPath = join6(repoPath, "pyproject.toml");
2334
+ if (existsSync4(pyprojectPath)) {
2128
2335
  const content = readFileSync4(pyprojectPath, "utf-8");
2129
2336
  if (content.includes("pytest")) testRunner = "pytest";
2130
2337
  }
@@ -3909,8 +4116,8 @@ function createAuthRouter(db2) {
3909
4116
  // cli/env-routes.ts
3910
4117
  init_esm_shims();
3911
4118
  import { Router as Router3 } from "express";
3912
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "fs";
3913
- 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";
3914
4121
 
3915
4122
  // cli/env-config.ts
3916
4123
  init_esm_shims();
@@ -4299,9 +4506,9 @@ function validateEnvValue(key, value) {
4299
4506
  // cli/env-routes.ts
4300
4507
  function createEnvRouter() {
4301
4508
  const router = Router3();
4302
- const ENV_FILE_PATH = join6(process.cwd(), ".env");
4509
+ const ENV_FILE_PATH = join7(process.cwd(), ".env");
4303
4510
  function readEnvFile() {
4304
- if (!existsSync4(ENV_FILE_PATH)) {
4511
+ if (!existsSync5(ENV_FILE_PATH)) {
4305
4512
  return {};
4306
4513
  }
4307
4514
  const content = readFileSync5(ENV_FILE_PATH, "utf-8");
@@ -4376,8 +4583,8 @@ function createEnvRouter() {
4376
4583
  }));
4377
4584
  res.json({
4378
4585
  variables,
4379
- envFileExists: existsSync4(ENV_FILE_PATH),
4380
- 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
4381
4588
  });
4382
4589
  } catch (error) {
4383
4590
  res.status(500).json({
@@ -7736,672 +7943,860 @@ function getEnvHTML() {
7736
7943
  <head>
7737
7944
  <meta charset="UTF-8">
7738
7945
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7739
- <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">
7740
7949
  <style>
7741
- * { margin: 0; padding: 0; box-sizing: border-box; }
7742
-
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
+
7743
7978
  body {
7744
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
7745
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
7979
+ font-family: var(--sans);
7980
+ background: var(--bg);
7981
+ color: var(--text-1);
7746
7982
  min-height: 100vh;
7747
- padding: 20px;
7983
+ overflow-x: hidden;
7748
7984
  }
7749
7985
 
7750
- .container {
7751
- max-width: 1200px;
7752
- margin: 0 auto;
7986
+ /* \u2500\u2500 Layout \u2500\u2500 */
7987
+ .shell {
7988
+ display: grid;
7989
+ grid-template-columns: 220px 1fr;
7990
+ min-height: 100vh;
7753
7991
  }
7754
7992
 
7755
- .header {
7756
- background: rgba(255, 255, 255, 0.95);
7757
- backdrop-filter: blur(10px);
7758
- padding: 20px 30px;
7759
- border-radius: 12px;
7760
- margin-bottom: 20px;
7761
- 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);
7762
7997
  display: flex;
7763
- justify-content: space-between;
7764
- align-items: center;
7998
+ flex-direction: column;
7999
+ padding: 28px 0;
8000
+ position: sticky;
8001
+ top: 0;
8002
+ height: 100vh;
7765
8003
  }
7766
8004
 
7767
- .header h1 {
7768
- font-size: 24px;
7769
- color: #1a202c;
8005
+ .logo {
7770
8006
  display: flex;
7771
8007
  align-items: center;
7772
8008
  gap: 10px;
8009
+ padding: 0 24px 32px;
8010
+ border-bottom: 1px solid var(--border);
8011
+ margin-bottom: 12px;
7773
8012
  }
7774
8013
 
7775
- .header-actions {
7776
- display: flex;
7777
- 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;
7778
8023
  }
7779
8024
 
7780
- .btn {
7781
- padding: 10px 20px;
7782
- border: none;
7783
- 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;
7784
8048
  font-size: 14px;
7785
8049
  font-weight: 600;
8050
+ transition: all 0.15s ease;
7786
8051
  cursor: pointer;
7787
- transition: all 0.2s;
7788
- text-decoration: none;
7789
- display: inline-flex;
7790
- align-items: center;
7791
- gap: 8px;
7792
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; }
7793
8056
 
7794
- .btn-primary {
7795
- background: #667eea;
7796
- color: white;
8057
+ .sidebar-footer {
8058
+ padding: 16px 24px;
8059
+ border-top: 1px solid var(--border);
7797
8060
  }
7798
8061
 
7799
- .btn-primary:hover {
7800
- background: #5568d3;
7801
- transform: translateY(-1px);
7802
- }
8062
+ /* \u2500\u2500 Main \u2500\u2500 */
8063
+ main { display: flex; flex-direction: column; min-height: 100vh; overflow-y: auto; }
7803
8064
 
7804
- .btn-secondary {
7805
- background: #e2e8f0;
7806
- 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;
7807
8075
  }
7808
8076
 
7809
- .btn-secondary:hover {
7810
- background: #cbd5e0;
7811
- }
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; }
7812
8079
 
7813
- .btn-success {
7814
- background: #48bb78;
7815
- color: white;
7816
- }
8080
+ .topbar-actions { display: flex; align-items: center; gap: 10px; }
7817
8081
 
7818
- .btn-success:hover {
7819
- 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;
7820
8095
  }
8096
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
7821
8097
 
7822
- .btn:disabled {
7823
- opacity: 0.5;
7824
- cursor: not-allowed;
8098
+ .btn-ghost {
8099
+ background: var(--panel);
8100
+ color: var(--text-2);
8101
+ border: 1px solid var(--border);
7825
8102
  }
8103
+ .btn-ghost:hover { border-color: var(--border-hi); color: var(--text-1); }
7826
8104
 
7827
- .content {
7828
- background: rgba(255, 255, 255, 0.95);
7829
- backdrop-filter: blur(10px);
7830
- padding: 30px;
7831
- border-radius: 12px;
7832
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
8105
+ .btn-primary {
8106
+ background: var(--accent);
8107
+ color: #fff;
7833
8108
  }
8109
+ .btn-primary:hover:not(:disabled) { background: #ea580c; box-shadow: 0 0 20px rgba(249,115,22,0.35); }
7834
8110
 
7835
- .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 {
7836
8116
  display: flex;
7837
- gap: 10px;
7838
- margin-bottom: 30px;
7839
- border-bottom: 2px solid #e2e8f0;
7840
- 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;
7841
8123
  }
7842
8124
 
7843
- .tab {
7844
- padding: 10px 20px;
8125
+ .tab-btn {
8126
+ padding: 7px 14px;
8127
+ background: transparent;
7845
8128
  border: none;
7846
- background: none;
7847
- font-size: 14px;
8129
+ border-radius: 7px;
8130
+ color: var(--text-3);
8131
+ font-family: var(--sans);
8132
+ font-size: 12px;
7848
8133
  font-weight: 600;
7849
- color: #718096;
7850
8134
  cursor: pointer;
7851
- border-bottom: 3px solid transparent;
7852
- transition: all 0.2s;
7853
- }
7854
-
7855
- .tab.active {
7856
- color: #667eea;
7857
- border-bottom-color: #667eea;
8135
+ transition: all 0.15s ease;
8136
+ white-space: nowrap;
8137
+ display: flex;
8138
+ align-items: center;
8139
+ gap: 5px;
7858
8140
  }
7859
-
7860
- .tab:hover {
7861
- 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);
7862
8146
  }
7863
-
7864
- .category-section {
7865
- display: none;
8147
+ .tab-btn .tab-dot {
8148
+ width: 6px; height: 6px;
8149
+ border-radius: 50%;
8150
+ background: var(--text-3);
7866
8151
  }
8152
+ .tab-btn.has-required .tab-dot { background: var(--amber); }
8153
+ .tab-btn.active .tab-dot { background: var(--accent); }
7867
8154
 
7868
- .category-section.active {
7869
- display: block;
7870
- }
8155
+ /* \u2500\u2500 Section \u2500\u2500 */
8156
+ .section { display: none; flex-direction: column; gap: 16px; }
8157
+ .section.active { display: flex; }
7871
8158
 
7872
- .category-header {
8159
+ .section-header {
7873
8160
  display: flex;
7874
- justify-content: space-between;
7875
8161
  align-items: center;
7876
- margin-bottom: 20px;
7877
- }
7878
-
7879
- .category-title {
7880
- font-size: 18px;
7881
- font-weight: 600;
7882
- color: #2d3748;
7883
- }
7884
-
7885
- .env-grid {
7886
- display: grid;
7887
- gap: 20px;
8162
+ gap: 12px;
8163
+ margin-bottom: 4px;
7888
8164
  }
7889
-
7890
- .env-item {
7891
- border: 1px solid #e2e8f0;
8165
+ .section-icon {
8166
+ width: 36px; height: 36px;
8167
+ background: var(--accent-lo);
8168
+ border: 1px solid var(--accent-md);
7892
8169
  border-radius: 8px;
7893
- padding: 20px;
7894
- transition: all 0.2s;
8170
+ display: grid;
8171
+ place-items: center;
8172
+ font-size: 16px;
7895
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; }
7896
8176
 
7897
- .env-item:hover {
7898
- border-color: #cbd5e0;
7899
- 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;
7900
8184
  }
8185
+ .env-card:hover { border-color: var(--border-hi); }
8186
+ .env-card.has-value { border-color: rgba(249,115,22,0.15); }
7901
8187
 
7902
- .env-item-header {
8188
+ .env-card-head {
7903
8189
  display: flex;
7904
8190
  justify-content: space-between;
7905
8191
  align-items: flex-start;
7906
- margin-bottom: 10px;
8192
+ margin-bottom: 6px;
7907
8193
  }
7908
8194
 
7909
- .env-label {
7910
- font-weight: 600;
7911
- color: #2d3748;
7912
- font-size: 14px;
8195
+ .env-key {
8196
+ font-family: var(--mono);
8197
+ font-size: 13px;
8198
+ font-weight: 500;
8199
+ color: var(--text-1);
7913
8200
  display: flex;
7914
8201
  align-items: center;
7915
8202
  gap: 8px;
7916
8203
  }
7917
8204
 
7918
- .required-badge {
7919
- background: #fc8181;
7920
- color: white;
7921
- font-size: 10px;
7922
- padding: 2px 6px;
7923
- border-radius: 4px;
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);
8214
+ border-radius: 4px;
8215
+ padding: 2px 6px;
8216
+ }
8217
+ .badge-sensitive {
8218
+ font-size: 9px;
7924
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;
7925
8228
  }
7926
8229
 
7927
- .env-description {
7928
- font-size: 13px;
7929
- color: #718096;
7930
- 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;
7931
8236
  }
7932
8237
 
7933
- .env-input-group {
8238
+ .env-input-row {
7934
8239
  display: flex;
7935
- gap: 10px;
8240
+ gap: 8px;
7936
8241
  align-items: center;
7937
8242
  }
7938
8243
 
7939
- .env-input {
8244
+ .env-input, .env-select {
7940
8245
  flex: 1;
7941
- padding: 10px 12px;
7942
- border: 1px solid #e2e8f0;
7943
- border-radius: 6px;
7944
- font-size: 14px;
7945
- font-family: 'Monaco', 'Courier New', monospace;
7946
- transition: all 0.2s;
7947
- }
7948
-
7949
- .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);
7950
8253
  outline: none;
7951
- border-color: #667eea;
7952
- 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;
7953
8256
  }
7954
-
7955
- .env-input.error {
7956
- 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);
7957
8260
  }
8261
+ .env-input.changed { border-color: rgba(249,115,22,0.5); }
8262
+ .env-input.invalid { border-color: var(--red); }
7958
8263
 
7959
- .env-actions {
7960
- display: flex;
7961
- gap: 5px;
7962
- }
8264
+ .env-select option { background: var(--panel); }
7963
8265
 
7964
- .icon-btn {
7965
- padding: 8px;
7966
- border: none;
7967
- background: #e2e8f0;
7968
- 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);
7969
8272
  cursor: pointer;
7970
- transition: all 0.2s;
7971
- font-size: 16px;
7972
- }
7973
-
7974
- .icon-btn:hover {
7975
- background: #cbd5e0;
7976
- }
7977
-
7978
- .icon-btn.test {
7979
- background: #bee3f8;
7980
- color: #2c5282;
7981
- }
7982
-
7983
- .icon-btn.test:hover {
7984
- background: #90cdf4;
7985
- }
7986
-
7987
- .icon-btn.generate {
7988
- background: #c6f6d5;
7989
- color: #22543d;
7990
- }
7991
-
7992
- .icon-btn.generate:hover {
7993
- background: #9ae6b4;
8273
+ display: grid;
8274
+ place-items: center;
8275
+ font-size: 14px;
8276
+ transition: all 0.15s;
8277
+ flex-shrink: 0;
7994
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); }
7995
8282
 
7996
- .error-message {
7997
- color: #e53e3e;
7998
- font-size: 12px;
7999
- margin-top: 5px;
8283
+ .env-feedback {
8284
+ font-family: var(--mono);
8285
+ font-size: 11px;
8286
+ margin-top: 8px;
8287
+ min-height: 16px;
8000
8288
  }
8289
+ .env-feedback.error { color: var(--red); }
8290
+ .env-feedback.success { color: var(--green); }
8001
8291
 
8002
- .success-message {
8003
- color: #38a169;
8004
- font-size: 12px;
8005
- 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;
8006
8301
  }
8007
8302
 
8008
- .alert {
8009
- padding: 15px 20px;
8010
- border-radius: 8px;
8011
- margin-bottom: 20px;
8303
+ .toast {
8304
+ padding: 12px 18px;
8305
+ border-radius: 10px;
8306
+ font-size: 13px;
8307
+ font-weight: 600;
8012
8308
  display: flex;
8013
8309
  align-items: center;
8014
8310
  gap: 10px;
8311
+ animation: slideIn 0.2s ease;
8312
+ max-width: 380px;
8015
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); }
8016
8318
 
8017
- .alert-warning {
8018
- background: #fef5e7;
8019
- border-left: 4px solid #f59e0b;
8020
- color: #92400e;
8319
+ @keyframes slideIn {
8320
+ from { opacity: 0; transform: translateY(8px); }
8321
+ to { opacity: 1; transform: translateY(0); }
8021
8322
  }
8022
8323
 
8023
- .alert-info {
8024
- background: #eff6ff;
8025
- border-left: 4px solid #3b82f6;
8026
- color: #1e40af;
8027
- }
8028
-
8029
- .alert-success {
8030
- background: #f0fdf4;
8031
- border-left: 4px solid #10b981;
8032
- 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;
8033
8332
  }
8333
+ .modal-backdrop.open { display: flex; }
8034
8334
 
8035
- .loading {
8036
- text-align: center;
8037
- padding: 40px;
8038
- 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;
8039
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; }
8040
8355
 
8356
+ /* \u2500\u2500 Spinner \u2500\u2500 */
8041
8357
  .spinner {
8042
- border: 3px solid #e2e8f0;
8043
- border-top: 3px solid #667eea;
8358
+ width: 36px; height: 36px;
8359
+ border: 3px solid var(--border);
8360
+ border-top-color: var(--accent);
8044
8361
  border-radius: 50%;
8045
- width: 40px;
8046
- height: 40px;
8047
- animation: spin 1s linear infinite;
8048
- margin: 0 auto 20px;
8362
+ animation: spin 0.8s linear infinite;
8363
+ margin: 0 auto 16px;
8049
8364
  }
8365
+ @keyframes spin { to { transform: rotate(360deg); } }
8050
8366
 
8051
- @keyframes spin {
8052
- 0% { transform: rotate(0deg); }
8053
- 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;
8054
8373
  }
8055
8374
 
8056
- .modal {
8375
+ /* \u2500\u2500 Restart banner \u2500\u2500 */
8376
+ .restart-banner {
8057
8377
  display: none;
8058
- position: fixed;
8059
- top: 0;
8060
- left: 0;
8061
- right: 0;
8062
- bottom: 0;
8063
- background: rgba(0, 0, 0, 0.5);
8064
- 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;
8065
8385
  align-items: center;
8066
- justify-content: center;
8386
+ gap: 10px;
8067
8387
  }
8388
+ .restart-banner.show { display: flex; }
8389
+ </style>
8390
+ </head>
8391
+ <body>
8392
+ <div class="shell">
8068
8393
 
8069
- .modal.show {
8070
- display: flex;
8071
- }
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>
8072
8403
 
8073
- .modal-content {
8074
- background: white;
8075
- padding: 30px;
8076
- border-radius: 12px;
8077
- max-width: 500px;
8078
- width: 90%;
8079
- box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
8080
- }
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>
8081
8415
 
8082
- .modal-header {
8083
- font-size: 20px;
8084
- font-weight: 600;
8085
- margin-bottom: 15px;
8086
- color: #2d3748;
8087
- }
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>
8088
8426
 
8089
- .modal-body {
8090
- margin-bottom: 20px;
8091
- color: #4a5568;
8092
- }
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>
8093
8438
 
8094
- .modal-footer {
8095
- display: flex;
8096
- gap: 10px;
8097
- justify-content: flex-end;
8098
- }
8099
- </style>
8100
- </head>
8101
- <body>
8102
- <div class="container">
8103
- <div class="header">
8104
- <h1>
8105
- <span>\u2699\uFE0F</span>
8439
+ <div class="sidebar-footer">
8440
+ <div style="font-family:var(--mono);font-size:11px;color:var(--text-3);">
8106
8441
  Environment Variables
8107
- </h1>
8108
- <div class="header-actions">
8109
- <a href="/config" class="btn btn-secondary">\u2190 Back to Config</a>
8110
- <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>
8111
8458
  </div>
8112
8459
  </div>
8113
8460
 
8114
8461
  <div class="content">
8115
- <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">
8116
8470
  <div class="spinner"></div>
8117
- <div>Loading environment variables...</div>
8471
+ Loading environment variables\u2026
8118
8472
  </div>
8119
8473
 
8120
- <div id="main" style="display: none;">
8121
- <div id="alerts"></div>
8122
-
8123
- <div class="tabs">
8124
- <button class="tab active" data-category="llm">\u{1F916} LLM</button>
8125
- <button class="tab" data-category="security">\u{1F512} Security</button>
8126
- <button class="tab" data-category="target">\u{1F3AF} Target App</button>
8127
- <button class="tab" data-category="github">\u{1F419} GitHub</button>
8128
- <button class="tab" data-category="web">\u{1F310} Web Server</button>
8129
- <button class="tab" data-category="agent">\u{1F916} Agent</button>
8130
- <button class="tab" data-category="database">\u{1F4BE} Database</button>
8131
- <button class="tab" data-category="notifications">\u{1F514} Notifications</button>
8132
- </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>
8133
8482
 
8134
- <div id="categories"></div>
8135
8483
  </div>
8136
8484
  </div>
8137
- </div>
8485
+ </main>
8486
+ </div>
8138
8487
 
8139
- <!-- Test Result Modal -->
8140
- <div id="testModal" class="modal">
8141
- <div class="modal-content">
8142
- <div class="modal-header">Test Result</div>
8143
- <div class="modal-body" id="testResult"></div>
8144
- <div class="modal-footer">
8145
- <button class="btn btn-secondary" onclick="closeTestModal()">Close</button>
8146
- </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>
8147
8497
  </div>
8148
8498
  </div>
8499
+ </div>
8149
8500
 
8150
- <script>
8151
- let envVariables = [];
8152
- let changedVariables = {};
8153
- let restartRequired = false;
8501
+ <!-- Toast zone -->
8502
+ <div class="toast-zone" id="toastZone"></div>
8154
8503
 
8155
- // Load environment variables
8156
- async function loadEnvVariables() {
8157
- try {
8158
- const response = await fetch('/api/env');
8159
- if (!response.ok) throw new Error('Failed to load variables');
8160
-
8161
- const data = await response.json();
8162
- envVariables = data.variables;
8163
-
8164
- renderCategories();
8165
- document.getElementById('loading').style.display = 'none';
8166
- document.getElementById('main').style.display = 'block';
8167
- } catch (error) {
8168
- showAlert('error', 'Failed to load environment variables: ' + error.message);
8169
- }
8170
- }
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
+ ];
8171
8520
 
8172
- // Render categories
8173
- function renderCategories() {
8174
- const container = document.getElementById('categories');
8175
- const categories = [...new Set(envVariables.map(v => v.category))];
8176
-
8177
- categories.forEach((category, index) => {
8178
- const section = document.createElement('div');
8179
- section.className = 'category-section' + (index === 0 ? ' active' : '');
8180
- section.dataset.category = category;
8181
-
8182
- const vars = envVariables.filter(v => v.category === category);
8183
-
8184
- section.innerHTML = \`
8185
- <div class="category-header">
8186
- <div class="category-title">\${getCategoryTitle(category)}</div>
8187
- </div>
8188
- <div class="env-grid">
8189
- \${vars.map(v => renderEnvItem(v)).join('')}
8190
- </div>
8191
- \`;
8192
-
8193
- container.appendChild(section);
8194
- });
8195
- }
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
+ }
8196
8536
 
8197
- // Render single env item
8198
- function renderEnvItem(envVar) {
8199
- const inputType = envVar.type === 'password' ? 'password' : 'text';
8200
- const value = envVar.displayValue || '';
8201
-
8202
- return \`
8203
- <div class="env-item" data-key="\${envVar.key}">
8204
- <div class="env-item-header">
8205
- <div class="env-label">
8206
- \${envVar.key}
8207
- \${envVar.required ? '<span class="required-badge">REQUIRED</span>' : ''}
8208
- </div>
8209
- </div>
8210
- <div class="env-description">\${envVar.description}</div>
8211
- <div class="env-input-group">
8212
- \${envVar.type === 'select' ?
8213
- \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
8214
- <option value="">-- Select --</option>
8215
- \${envVar.options.map(opt =>
8216
- \`<option value="\${opt}" \${value === opt ? 'selected' : ''}>\${opt}</option>\`
8217
- ).join('')}
8218
- </select>\` :
8219
- envVar.type === 'boolean' ?
8220
- \`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
8221
- <option value="">-- Select --</option>
8222
- <option value="true" \${value === 'true' ? 'selected' : ''}>true</option>
8223
- <option value="false" \${value === 'false' ? 'selected' : ''}>false</option>
8224
- </select>\` :
8225
- \`<input
8226
- type="\${inputType}"
8227
- class="env-input"
8228
- data-key="\${envVar.key}"
8229
- value="\${value}"
8230
- placeholder="\${envVar.placeholder || ''}"
8231
- onchange="handleChange(this)"
8232
- />\`
8233
- }
8234
- <div class="env-actions">
8235
- \${envVar.testable ? \`<button class="icon-btn test" onclick="testVariable('\${envVar.key}')" title="Test">\u{1F9EA}</button>\` : ''}
8236
- \${envVar.key === 'OPENQA_JWT_SECRET' ? \`<button class="icon-btn generate" onclick="generateSecret('\${envVar.key}')" title="Generate">\u{1F511}</button>\` : ''}
8237
- </div>
8238
- </div>
8239
- <div class="error-message" id="error-\${envVar.key}"></div>
8240
- <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>
8241
8566
  </div>
8242
- \`;
8243
- }
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
+ }
8244
8573
 
8245
- // Handle input change
8246
- function handleChange(input) {
8247
- const key = input.dataset.key;
8248
- const value = input.value;
8249
-
8250
- changedVariables[key] = value;
8251
- document.getElementById('saveBtn').disabled = false;
8252
-
8253
- // Clear messages
8254
- document.getElementById(\`error-\${key}\`).textContent = '';
8255
- document.getElementById(\`success-\${key}\`).textContent = '';
8256
- }
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
+ }
8257
8630
 
8258
- // Save changes
8259
- async function saveChanges() {
8260
- const saveBtn = document.getElementById('saveBtn');
8261
- saveBtn.disabled = true;
8262
- saveBtn.textContent = '\u{1F4BE} Saving...';
8263
-
8264
- try {
8265
- const response = await fetch('/api/env/bulk', {
8266
- method: 'POST',
8267
- headers: { 'Content-Type': 'application/json' },
8268
- body: JSON.stringify({ variables: changedVariables }),
8269
- });
8270
-
8271
- if (!response.ok) {
8272
- const error = await response.json();
8273
- 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');
8274
8683
  }
8275
-
8276
- const result = await response.json();
8277
- restartRequired = result.restartRequired;
8278
-
8279
- showAlert('success', \`\u2705 Saved \${result.updated} variable(s) successfully!\` +
8280
- (restartRequired ? ' \u26A0\uFE0F Restart required for changes to take effect.' : ''));
8281
-
8282
- changedVariables = {};
8283
- saveBtn.textContent = '\u{1F4BE} Save Changes';
8284
-
8285
- // Reload to show updated values
8286
- setTimeout(() => location.reload(), 2000);
8287
- } catch (error) {
8288
- showAlert('error', 'Failed to save: ' + error.message);
8289
- saveBtn.disabled = false;
8290
- saveBtn.textContent = '\u{1F4BE} Save Changes';
8291
8684
  }
8685
+ toast('error', errStr);
8686
+ btn.disabled = false;
8687
+ btn.innerHTML = '\u{1F4BE} Save Changes';
8688
+ return;
8292
8689
  }
8293
8690
 
8294
- // Test variable
8295
- async function testVariable(key) {
8296
- const input = document.querySelector(\`[data-key="\${key}"]\`);
8297
- const value = input.value;
8298
-
8299
- if (!value) {
8300
- showAlert('warning', 'Please enter a value first');
8301
- return;
8302
- }
8303
-
8304
- try {
8305
- const response = await fetch(\`/api/env/test/\${key}\`, {
8306
- method: 'POST',
8307
- headers: { 'Content-Type': 'application/json' },
8308
- body: JSON.stringify({ value }),
8309
- });
8310
-
8311
- const result = await response.json();
8312
- showTestResult(result);
8313
- } catch (error) {
8314
- showTestResult({ success: false, message: 'Test failed: ' + error.message });
8315
- }
8691
+ toast('success', '\u2705 Saved ' + body.updated + ' variable(s)');
8692
+ if (body.restartRequired) {
8693
+ document.getElementById('restartBanner').classList.add('show');
8316
8694
  }
8317
8695
 
8318
- // Generate secret
8319
- async function generateSecret(key) {
8320
- try {
8321
- const response = await fetch(\`/api/env/generate/\${key}\`, {
8322
- method: 'POST',
8323
- });
8324
-
8325
- if (!response.ok) throw new Error('Failed to generate');
8326
-
8327
- const result = await response.json();
8328
- const input = document.querySelector(\`[data-key="\${key}"]\`);
8329
- input.value = result.value;
8330
- handleChange(input);
8331
-
8332
- document.getElementById(\`success-\${key}\`).textContent = '\u2705 Secret generated!';
8333
- } catch (error) {
8334
- document.getElementById(\`error-\${key}\`).textContent = 'Failed to generate: ' + error.message;
8335
- }
8336
- }
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
+ }
8337
8706
 
8338
- // Show test result
8339
- function showTestResult(result) {
8340
- const modal = document.getElementById('testModal');
8341
- const resultDiv = document.getElementById('testResult');
8342
-
8343
- resultDiv.innerHTML = \`
8344
- <div class="alert \${result.success ? 'alert-success' : 'alert-warning'}">
8345
- \${result.success ? '\u2705' : '\u274C'} \${result.message}
8346
- </div>
8347
- \`;
8348
-
8349
- modal.classList.add('show');
8350
- }
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; }
8351
8712
 
8352
- function closeTestModal() {
8353
- document.getElementById('testModal').classList.remove('show');
8354
- }
8713
+ setFeedback(key, '', '');
8714
+ const btn = document.querySelector('[onclick="testVar(\\''+key+'\\')"]');
8715
+ if (btn) { btn.textContent = '\u23F3'; btn.disabled = true; }
8355
8716
 
8356
- // Show alert
8357
- function showAlert(type, message) {
8358
- const alerts = document.getElementById('alerts');
8359
- const alertClass = type === 'error' ? 'alert-warning' :
8360
- type === 'success' ? 'alert-success' : 'alert-info';
8361
-
8362
- alerts.innerHTML = \`
8363
- <div class="alert \${alertClass}">
8364
- \${message}
8365
- </div>
8366
- \`;
8367
-
8368
- setTimeout(() => alerts.innerHTML = '', 5000);
8369
- }
8370
-
8371
- // Get category title
8372
- function getCategoryTitle(category) {
8373
- const titles = {
8374
- llm: '\u{1F916} LLM Configuration',
8375
- security: '\u{1F512} Security Settings',
8376
- target: '\u{1F3AF} Target Application',
8377
- github: '\u{1F419} GitHub Integration',
8378
- web: '\u{1F310} Web Server',
8379
- agent: '\u{1F916} Agent Configuration',
8380
- database: '\u{1F4BE} Database',
8381
- notifications: '\u{1F514} Notifications',
8382
- };
8383
- return titles[category] || category;
8384
- }
8385
-
8386
- // Tab switching
8387
- document.addEventListener('click', (e) => {
8388
- if (e.target.classList.contains('tab')) {
8389
- const category = e.target.dataset.category;
8390
-
8391
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
8392
- e.target.classList.add('active');
8393
-
8394
- document.querySelectorAll('.category-section').forEach(s => s.classList.remove('active'));
8395
- document.querySelector(\`[data-category="\${category}"]\`).classList.add('active');
8396
- }
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',
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
+ }
8733
+
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'
8397
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
+ }
8398
8753
 
8399
- // Save button
8400
- document.getElementById('saveBtn').addEventListener('click', saveChanges);
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
+ }
8401
8764
 
8402
- // Load on page load
8403
- loadEnvVariables();
8404
- </script>
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>
8405
8800
  </body>
8406
8801
  </html>`;
8407
8802
  }