@openqa/cli 2.1.1 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/agent/index-v2.js +244 -37
- package/dist/agent/index-v2.js.map +1 -1
- package/dist/agent/index.js +24 -2
- package/dist/agent/index.js.map +1 -1
- package/dist/cli/daemon.js +960 -565
- package/dist/cli/env.html.js +717 -529
- package/dist/cli/index.js +709 -521
- package/dist/cli/server.js +709 -521
- package/package.json +1 -1
package/dist/cli/daemon.js
CHANGED
|
@@ -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
|
|
865
|
-
import { join as
|
|
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
|
|
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 =
|
|
1188
|
-
if (!
|
|
1329
|
+
repoPath = join4(this.workDir, "repo");
|
|
1330
|
+
if (!existsSync3(repoPath)) {
|
|
1189
1331
|
logger.info("Cloning repository", { url: this.saasConfig.repoUrl });
|
|
1190
1332
|
try {
|
|
1191
|
-
|
|
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 || !
|
|
1342
|
+
if (!repoPath || !existsSync3(repoPath)) {
|
|
1201
1343
|
return "";
|
|
1202
1344
|
}
|
|
1203
1345
|
const analysis = [];
|
|
1204
1346
|
try {
|
|
1205
|
-
const packageJsonPath =
|
|
1206
|
-
if (
|
|
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 =
|
|
1232
|
-
if (
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 &&
|
|
1402
|
-
const result =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
2007
|
-
import { join as
|
|
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 =
|
|
2036
|
-
if (
|
|
2242
|
+
const pkgPath = join6(repoPath, "package.json");
|
|
2243
|
+
if (existsSync4(pkgPath)) {
|
|
2037
2244
|
return this.detectNodeProject(repoPath, pkgPath);
|
|
2038
2245
|
}
|
|
2039
|
-
if (
|
|
2246
|
+
if (existsSync4(join6(repoPath, "requirements.txt")) || existsSync4(join6(repoPath, "pyproject.toml"))) {
|
|
2040
2247
|
return this.detectPythonProject(repoPath);
|
|
2041
2248
|
}
|
|
2042
|
-
if (
|
|
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 (
|
|
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 (
|
|
2068
|
-
else if (
|
|
2069
|
-
else if (
|
|
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 =
|
|
2122
|
-
if (
|
|
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 =
|
|
2127
|
-
if (
|
|
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
|
|
3913
|
-
import { join as
|
|
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 =
|
|
4509
|
+
const ENV_FILE_PATH = join7(process.cwd(), ".env");
|
|
4303
4510
|
function readEnvFile() {
|
|
4304
|
-
if (!
|
|
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:
|
|
4380
|
-
lastModified:
|
|
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>
|
|
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
|
-
|
|
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:
|
|
7745
|
-
background:
|
|
7979
|
+
font-family: var(--sans);
|
|
7980
|
+
background: var(--bg);
|
|
7981
|
+
color: var(--text-1);
|
|
7746
7982
|
min-height: 100vh;
|
|
7747
|
-
|
|
7983
|
+
overflow-x: hidden;
|
|
7748
7984
|
}
|
|
7749
7985
|
|
|
7750
|
-
|
|
7751
|
-
|
|
7752
|
-
|
|
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
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
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
|
-
|
|
7764
|
-
|
|
7998
|
+
flex-direction: column;
|
|
7999
|
+
padding: 28px 0;
|
|
8000
|
+
position: sticky;
|
|
8001
|
+
top: 0;
|
|
8002
|
+
height: 100vh;
|
|
7765
8003
|
}
|
|
7766
8004
|
|
|
7767
|
-
.
|
|
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
|
-
.
|
|
7776
|
-
|
|
7777
|
-
|
|
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
|
-
.
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
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
|
-
.
|
|
7795
|
-
|
|
7796
|
-
|
|
8057
|
+
.sidebar-footer {
|
|
8058
|
+
padding: 16px 24px;
|
|
8059
|
+
border-top: 1px solid var(--border);
|
|
7797
8060
|
}
|
|
7798
8061
|
|
|
7799
|
-
|
|
7800
|
-
|
|
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
|
-
.
|
|
7805
|
-
|
|
7806
|
-
|
|
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
|
-
.
|
|
7810
|
-
|
|
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
|
-
.
|
|
7814
|
-
background: #48bb78;
|
|
7815
|
-
color: white;
|
|
7816
|
-
}
|
|
8080
|
+
.topbar-actions { display: flex; align-items: center; gap: 10px; }
|
|
7817
8081
|
|
|
7818
|
-
.btn
|
|
7819
|
-
|
|
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
|
|
7823
|
-
|
|
7824
|
-
|
|
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
|
-
.
|
|
7828
|
-
background:
|
|
7829
|
-
|
|
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
|
-
|
|
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:
|
|
7838
|
-
|
|
7839
|
-
border
|
|
7840
|
-
|
|
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:
|
|
8125
|
+
.tab-btn {
|
|
8126
|
+
padding: 7px 14px;
|
|
8127
|
+
background: transparent;
|
|
7845
8128
|
border: none;
|
|
7846
|
-
|
|
7847
|
-
|
|
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
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
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
|
|
7861
|
-
|
|
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
|
-
|
|
7865
|
-
|
|
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
|
-
|
|
7869
|
-
|
|
7870
|
-
}
|
|
8155
|
+
/* \u2500\u2500 Section \u2500\u2500 */
|
|
8156
|
+
.section { display: none; flex-direction: column; gap: 16px; }
|
|
8157
|
+
.section.active { display: flex; }
|
|
7871
8158
|
|
|
7872
|
-
.
|
|
8159
|
+
.section-header {
|
|
7873
8160
|
display: flex;
|
|
7874
|
-
justify-content: space-between;
|
|
7875
8161
|
align-items: center;
|
|
7876
|
-
|
|
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
|
-
|
|
7891
|
-
|
|
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
|
-
|
|
7894
|
-
|
|
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
|
-
|
|
7898
|
-
|
|
7899
|
-
|
|
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-
|
|
8188
|
+
.env-card-head {
|
|
7903
8189
|
display: flex;
|
|
7904
8190
|
justify-content: space-between;
|
|
7905
8191
|
align-items: flex-start;
|
|
7906
|
-
margin-bottom:
|
|
8192
|
+
margin-bottom: 6px;
|
|
7907
8193
|
}
|
|
7908
8194
|
|
|
7909
|
-
.env-
|
|
7910
|
-
font-
|
|
7911
|
-
|
|
7912
|
-
font-
|
|
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
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
font-
|
|
7922
|
-
|
|
7923
|
-
|
|
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-
|
|
7928
|
-
font-
|
|
7929
|
-
|
|
7930
|
-
|
|
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-
|
|
8238
|
+
.env-input-row {
|
|
7934
8239
|
display: flex;
|
|
7935
|
-
gap:
|
|
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
|
-
|
|
7942
|
-
border: 1px solid
|
|
7943
|
-
border-radius:
|
|
7944
|
-
|
|
7945
|
-
font-family:
|
|
7946
|
-
|
|
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
|
|
7952
|
-
|
|
8254
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
8255
|
+
appearance: none;
|
|
7953
8256
|
}
|
|
7954
|
-
|
|
7955
|
-
|
|
7956
|
-
|
|
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-
|
|
7960
|
-
display: flex;
|
|
7961
|
-
gap: 5px;
|
|
7962
|
-
}
|
|
8264
|
+
.env-select option { background: var(--panel); }
|
|
7963
8265
|
|
|
7964
|
-
.
|
|
7965
|
-
|
|
7966
|
-
border:
|
|
7967
|
-
|
|
7968
|
-
|
|
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
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
|
|
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
|
-
.
|
|
7997
|
-
|
|
7998
|
-
font-size:
|
|
7999
|
-
margin-top:
|
|
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
|
-
|
|
8003
|
-
|
|
8004
|
-
|
|
8005
|
-
|
|
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
|
-
.
|
|
8009
|
-
padding:
|
|
8010
|
-
border-radius:
|
|
8011
|
-
|
|
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
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
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
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
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
|
-
.
|
|
8036
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
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
|
-
|
|
8043
|
-
border
|
|
8358
|
+
width: 36px; height: 36px;
|
|
8359
|
+
border: 3px solid var(--border);
|
|
8360
|
+
border-top-color: var(--accent);
|
|
8044
8361
|
border-radius: 50%;
|
|
8045
|
-
|
|
8046
|
-
|
|
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
|
-
|
|
8052
|
-
|
|
8053
|
-
|
|
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
|
-
|
|
8375
|
+
/* \u2500\u2500 Restart banner \u2500\u2500 */
|
|
8376
|
+
.restart-banner {
|
|
8057
8377
|
display: none;
|
|
8058
|
-
|
|
8059
|
-
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
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
|
-
|
|
8386
|
+
gap: 10px;
|
|
8067
8387
|
}
|
|
8388
|
+
.restart-banner.show { display: flex; }
|
|
8389
|
+
</style>
|
|
8390
|
+
</head>
|
|
8391
|
+
<body>
|
|
8392
|
+
<div class="shell">
|
|
8068
8393
|
|
|
8069
|
-
|
|
8070
|
-
|
|
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
|
-
|
|
8074
|
-
|
|
8075
|
-
|
|
8076
|
-
|
|
8077
|
-
|
|
8078
|
-
|
|
8079
|
-
|
|
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
|
-
|
|
8083
|
-
|
|
8084
|
-
|
|
8085
|
-
|
|
8086
|
-
|
|
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
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
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
|
-
|
|
8095
|
-
|
|
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
|
-
</
|
|
8108
|
-
|
|
8109
|
-
|
|
8110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8471
|
+
Loading environment variables\u2026
|
|
8118
8472
|
</div>
|
|
8119
8473
|
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
8124
|
-
|
|
8125
|
-
|
|
8126
|
-
|
|
8127
|
-
|
|
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
|
-
</
|
|
8485
|
+
</main>
|
|
8486
|
+
</div>
|
|
8138
8487
|
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
|
|
8143
|
-
|
|
8144
|
-
<div class="modal-
|
|
8145
|
-
|
|
8146
|
-
|
|
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
|
-
|
|
8151
|
-
|
|
8152
|
-
let changedVariables = {};
|
|
8153
|
-
let restartRequired = false;
|
|
8501
|
+
<!-- Toast zone -->
|
|
8502
|
+
<div class="toast-zone" id="toastZone"></div>
|
|
8154
8503
|
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
|
|
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
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
|
|
8176
|
-
|
|
8177
|
-
|
|
8178
|
-
|
|
8179
|
-
|
|
8180
|
-
|
|
8181
|
-
|
|
8182
|
-
|
|
8183
|
-
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
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
|
-
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
|
|
8201
|
-
|
|
8202
|
-
|
|
8203
|
-
|
|
8204
|
-
|
|
8205
|
-
|
|
8206
|
-
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
|
|
8215
|
-
|
|
8216
|
-
|
|
8217
|
-
|
|
8218
|
-
|
|
8219
|
-
|
|
8220
|
-
|
|
8221
|
-
|
|
8222
|
-
|
|
8223
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
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
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
|
|
8255
|
-
|
|
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
|
-
|
|
8259
|
-
|
|
8260
|
-
|
|
8261
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
|
|
8266
|
-
|
|
8267
|
-
|
|
8268
|
-
|
|
8269
|
-
|
|
8270
|
-
|
|
8271
|
-
|
|
8272
|
-
|
|
8273
|
-
|
|
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
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
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
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
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
|
-
|
|
8339
|
-
|
|
8340
|
-
|
|
8341
|
-
|
|
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
|
-
|
|
8353
|
-
|
|
8354
|
-
|
|
8713
|
+
setFeedback(key, '', '');
|
|
8714
|
+
const btn = document.querySelector('[onclick="testVar(\\''+key+'\\')"]');
|
|
8715
|
+
if (btn) { btn.textContent = '\u23F3'; btn.disabled = true; }
|
|
8355
8716
|
|
|
8356
|
-
|
|
8357
|
-
|
|
8358
|
-
|
|
8359
|
-
|
|
8360
|
-
|
|
8361
|
-
|
|
8362
|
-
|
|
8363
|
-
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
|
|
8367
|
-
|
|
8368
|
-
|
|
8369
|
-
}
|
|
8370
|
-
|
|
8371
|
-
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
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
|
-
|
|
8400
|
-
|
|
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
|
-
|
|
8403
|
-
|
|
8404
|
-
|
|
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 => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[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
|
}
|