@openqa/cli 2.0.0 → 2.1.1
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 +202 -5
- package/dist/agent/index-v2.js +33 -55
- package/dist/agent/index-v2.js.map +1 -1
- package/dist/agent/index.js +85 -116
- package/dist/agent/index.js.map +1 -1
- package/dist/cli/daemon.js +1530 -277
- package/dist/cli/dashboard.html.js +55 -15
- package/dist/cli/env-config.js +391 -0
- package/dist/cli/env-routes.js +820 -0
- package/dist/cli/env.html.js +679 -0
- package/dist/cli/index.js +4568 -2317
- package/dist/cli/server.js +2212 -19
- package/install.sh +19 -10
- package/package.json +2 -1
package/dist/cli/server.js
CHANGED
|
@@ -1468,6 +1468,1049 @@ function createApiRouter(db, config) {
|
|
|
1468
1468
|
return router;
|
|
1469
1469
|
}
|
|
1470
1470
|
|
|
1471
|
+
// cli/auth/router.ts
|
|
1472
|
+
init_esm_shims();
|
|
1473
|
+
import { Router as Router2 } from "express";
|
|
1474
|
+
import { z as z3 } from "zod";
|
|
1475
|
+
|
|
1476
|
+
// cli/auth/passwords.ts
|
|
1477
|
+
init_esm_shims();
|
|
1478
|
+
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
|
|
1479
|
+
import { promisify } from "util";
|
|
1480
|
+
var scryptAsync = promisify(scrypt);
|
|
1481
|
+
var KEYLEN = 64;
|
|
1482
|
+
async function hashPassword(plain) {
|
|
1483
|
+
const salt = randomBytes(16).toString("hex");
|
|
1484
|
+
const hash = await scryptAsync(plain, salt, KEYLEN);
|
|
1485
|
+
return `$scrypt$${salt}$${hash.toString("hex")}`;
|
|
1486
|
+
}
|
|
1487
|
+
async function verifyPassword(plain, stored) {
|
|
1488
|
+
const parts = stored.split("$");
|
|
1489
|
+
if (parts.length !== 4 || parts[1] !== "scrypt") return false;
|
|
1490
|
+
const [, , salt, hashHex] = parts;
|
|
1491
|
+
const storedHash = Buffer.from(hashHex, "hex");
|
|
1492
|
+
const derived = await scryptAsync(plain, salt, KEYLEN);
|
|
1493
|
+
if (derived.length !== storedHash.length) return false;
|
|
1494
|
+
return timingSafeEqual(derived, storedHash);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// cli/auth/jwt.ts
|
|
1498
|
+
init_esm_shims();
|
|
1499
|
+
import { createHmac, randomBytes as randomBytes2 } from "crypto";
|
|
1500
|
+
import { parse as parseCookies } from "cookie";
|
|
1501
|
+
var TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1502
|
+
var COOKIE_NAME = "openqa_token";
|
|
1503
|
+
var _secret = null;
|
|
1504
|
+
function getSecret() {
|
|
1505
|
+
if (_secret) return _secret;
|
|
1506
|
+
_secret = process.env.OPENQA_JWT_SECRET ?? null;
|
|
1507
|
+
if (!_secret) {
|
|
1508
|
+
_secret = randomBytes2(32).toString("hex");
|
|
1509
|
+
console.warn("[OpenQA] OPENQA_JWT_SECRET not set \u2014 using a volatile secret. All sessions will be invalidated on restart.");
|
|
1510
|
+
}
|
|
1511
|
+
return _secret;
|
|
1512
|
+
}
|
|
1513
|
+
function b64url(input) {
|
|
1514
|
+
return Buffer.from(input).toString("base64url");
|
|
1515
|
+
}
|
|
1516
|
+
function fromB64url(input) {
|
|
1517
|
+
return Buffer.from(input, "base64url").toString("utf8");
|
|
1518
|
+
}
|
|
1519
|
+
function signToken(payload) {
|
|
1520
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1521
|
+
const full = { ...payload, iat: now, exp: now + Math.floor(TTL_MS / 1e3) };
|
|
1522
|
+
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
1523
|
+
const body = b64url(JSON.stringify(full));
|
|
1524
|
+
const sig = createHmac("sha256", getSecret()).update(`${header}.${body}`).digest("base64url");
|
|
1525
|
+
return `${header}.${body}.${sig}`;
|
|
1526
|
+
}
|
|
1527
|
+
function verifyToken(token) {
|
|
1528
|
+
try {
|
|
1529
|
+
const [header, body, sig] = token.split(".");
|
|
1530
|
+
if (!header || !body || !sig) return null;
|
|
1531
|
+
const expected = createHmac("sha256", getSecret()).update(`${header}.${body}`).digest("base64url");
|
|
1532
|
+
if (expected !== sig) return null;
|
|
1533
|
+
const payload = JSON.parse(fromB64url(body));
|
|
1534
|
+
if (payload.exp < Math.floor(Date.now() / 1e3)) return null;
|
|
1535
|
+
return payload;
|
|
1536
|
+
} catch {
|
|
1537
|
+
return null;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
function setAuthCookie(res, token) {
|
|
1541
|
+
res.cookie(COOKIE_NAME, token, {
|
|
1542
|
+
httpOnly: true,
|
|
1543
|
+
sameSite: "lax",
|
|
1544
|
+
secure: process.env.NODE_ENV === "production",
|
|
1545
|
+
maxAge: TTL_MS,
|
|
1546
|
+
path: "/"
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
function clearAuthCookie(res) {
|
|
1550
|
+
res.clearCookie(COOKIE_NAME, { path: "/" });
|
|
1551
|
+
}
|
|
1552
|
+
function extractToken(req) {
|
|
1553
|
+
const cookieHeader = req.headers.cookie ?? "";
|
|
1554
|
+
if (cookieHeader) {
|
|
1555
|
+
const cookies = parseCookies(cookieHeader);
|
|
1556
|
+
if (cookies[COOKIE_NAME]) return cookies[COOKIE_NAME];
|
|
1557
|
+
}
|
|
1558
|
+
const auth = req.headers.authorization ?? "";
|
|
1559
|
+
if (auth.startsWith("Bearer ")) return auth.slice(7);
|
|
1560
|
+
return null;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// cli/auth/middleware.ts
|
|
1564
|
+
init_esm_shims();
|
|
1565
|
+
var AUTH_DISABLED = process.env.OPENQA_AUTH_DISABLED === "true";
|
|
1566
|
+
function requireAuth(req, res, next) {
|
|
1567
|
+
if (AUTH_DISABLED) {
|
|
1568
|
+
req.user = { sub: "dev", username: "dev", role: "admin", iat: 0, exp: 0 };
|
|
1569
|
+
return next();
|
|
1570
|
+
}
|
|
1571
|
+
const token = extractToken(req);
|
|
1572
|
+
const payload = token ? verifyToken(token) : null;
|
|
1573
|
+
if (!payload) {
|
|
1574
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
req.user = payload;
|
|
1578
|
+
next();
|
|
1579
|
+
}
|
|
1580
|
+
function requireAdmin(req, res, next) {
|
|
1581
|
+
const user = req.user;
|
|
1582
|
+
if (!user || user.role !== "admin") {
|
|
1583
|
+
res.status(403).json({ error: "Forbidden \u2014 admin only" });
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
next();
|
|
1587
|
+
}
|
|
1588
|
+
function authOrRedirect(db) {
|
|
1589
|
+
return async (req, res, next) => {
|
|
1590
|
+
if (AUTH_DISABLED) return next();
|
|
1591
|
+
const token = extractToken(req);
|
|
1592
|
+
const payload = token ? verifyToken(token) : null;
|
|
1593
|
+
if (payload) {
|
|
1594
|
+
req.user = payload;
|
|
1595
|
+
return next();
|
|
1596
|
+
}
|
|
1597
|
+
const count = await db.countUsers();
|
|
1598
|
+
if (count === 0) {
|
|
1599
|
+
res.redirect("/setup");
|
|
1600
|
+
} else {
|
|
1601
|
+
res.redirect("/login");
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// cli/auth/router.ts
|
|
1607
|
+
function validate2(schema) {
|
|
1608
|
+
return (req, res, next) => {
|
|
1609
|
+
const r = schema.safeParse(req.body);
|
|
1610
|
+
if (!r.success) {
|
|
1611
|
+
res.status(400).json({ error: r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ") });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
req.body = r.data;
|
|
1615
|
+
next();
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
var loginSchema = z3.object({
|
|
1619
|
+
username: z3.string().min(1).max(100),
|
|
1620
|
+
password: z3.string().min(1).max(200)
|
|
1621
|
+
});
|
|
1622
|
+
var setupSchema = z3.object({
|
|
1623
|
+
username: z3.string().min(3).max(100).regex(/^[a-z0-9_.@-]+$/, "Only lowercase letters, digits, and ._@- characters"),
|
|
1624
|
+
password: z3.string().min(8).max(200)
|
|
1625
|
+
});
|
|
1626
|
+
var changePasswordSchema = z3.object({
|
|
1627
|
+
currentPassword: z3.string().min(1),
|
|
1628
|
+
newPassword: z3.string().min(8).max(200)
|
|
1629
|
+
});
|
|
1630
|
+
var createUserSchema = z3.object({
|
|
1631
|
+
username: z3.string().min(3).max(100).regex(/^[a-z0-9_.@-]+$/, "Only lowercase letters, digits, and ._@- characters"),
|
|
1632
|
+
password: z3.string().min(8).max(200),
|
|
1633
|
+
role: z3.enum(["admin", "viewer"])
|
|
1634
|
+
});
|
|
1635
|
+
var updateUserSchema = z3.object({
|
|
1636
|
+
role: z3.enum(["admin", "viewer"]).optional(),
|
|
1637
|
+
password: z3.string().min(8).max(200).optional()
|
|
1638
|
+
});
|
|
1639
|
+
function createAuthRouter(db) {
|
|
1640
|
+
const router = Router2();
|
|
1641
|
+
router.post("/api/auth/login", validate2(loginSchema), async (req, res) => {
|
|
1642
|
+
const { username, password } = req.body;
|
|
1643
|
+
const user = await db.findUserByUsername(username);
|
|
1644
|
+
if (!user) {
|
|
1645
|
+
res.status(401).json({ error: "Invalid credentials" });
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
const ok = await verifyPassword(password, user.passwordHash);
|
|
1649
|
+
if (!ok) {
|
|
1650
|
+
res.status(401).json({ error: "Invalid credentials" });
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
const token = signToken({ sub: user.id, username: user.username, role: user.role });
|
|
1654
|
+
setAuthCookie(res, token);
|
|
1655
|
+
res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
|
1656
|
+
});
|
|
1657
|
+
router.post("/api/auth/logout", (_req, res) => {
|
|
1658
|
+
clearAuthCookie(res);
|
|
1659
|
+
res.json({ success: true });
|
|
1660
|
+
});
|
|
1661
|
+
router.get("/api/auth/me", requireAuth, (req, res) => {
|
|
1662
|
+
const { sub, username, role } = req.user;
|
|
1663
|
+
res.json({ id: sub, username, role });
|
|
1664
|
+
});
|
|
1665
|
+
router.post("/api/auth/change-password", requireAuth, validate2(changePasswordSchema), async (req, res) => {
|
|
1666
|
+
const { currentPassword, newPassword } = req.body;
|
|
1667
|
+
const userId = req.user.sub;
|
|
1668
|
+
const user = await db.getUserById(userId);
|
|
1669
|
+
if (!user) {
|
|
1670
|
+
res.status(404).json({ error: "User not found" });
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
const ok = await verifyPassword(currentPassword, user.passwordHash);
|
|
1674
|
+
if (!ok) {
|
|
1675
|
+
res.status(401).json({ error: "Current password is incorrect" });
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const passwordHash = await hashPassword(newPassword);
|
|
1679
|
+
await db.updateUser(userId, { passwordHash });
|
|
1680
|
+
res.json({ success: true });
|
|
1681
|
+
});
|
|
1682
|
+
router.post("/api/setup", validate2(setupSchema), async (req, res) => {
|
|
1683
|
+
const count = await db.countUsers();
|
|
1684
|
+
if (count > 0) {
|
|
1685
|
+
res.status(409).json({ error: "Setup already complete" });
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
const { username, password } = req.body;
|
|
1689
|
+
const existing = await db.findUserByUsername(username);
|
|
1690
|
+
if (existing) {
|
|
1691
|
+
res.status(409).json({ error: "Username already taken" });
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
const passwordHash = await hashPassword(password);
|
|
1695
|
+
const user = await db.createUser({ username, passwordHash, role: "admin" });
|
|
1696
|
+
const token = signToken({ sub: user.id, username: user.username, role: user.role });
|
|
1697
|
+
setAuthCookie(res, token);
|
|
1698
|
+
res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
|
1699
|
+
});
|
|
1700
|
+
router.get("/api/accounts", requireAuth, requireAdmin, async (_req, res) => {
|
|
1701
|
+
const users = await db.getAllUsers();
|
|
1702
|
+
res.json(users.map((u) => ({ id: u.id, username: u.username, role: u.role, createdAt: u.createdAt })));
|
|
1703
|
+
});
|
|
1704
|
+
router.post("/api/accounts", requireAuth, requireAdmin, validate2(createUserSchema), async (req, res) => {
|
|
1705
|
+
const { username, password, role } = req.body;
|
|
1706
|
+
const existing = await db.findUserByUsername(username);
|
|
1707
|
+
if (existing) {
|
|
1708
|
+
res.status(409).json({ error: "Username already taken" });
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const passwordHash = await hashPassword(password);
|
|
1712
|
+
const user = await db.createUser({ username, passwordHash, role });
|
|
1713
|
+
res.status(201).json({ id: user.id, username: user.username, role: user.role, createdAt: user.createdAt });
|
|
1714
|
+
});
|
|
1715
|
+
router.put("/api/accounts/:id", requireAuth, requireAdmin, validate2(updateUserSchema), async (req, res) => {
|
|
1716
|
+
const { id } = req.params;
|
|
1717
|
+
const user = await db.getUserById(id);
|
|
1718
|
+
if (!user) {
|
|
1719
|
+
res.status(404).json({ error: "User not found" });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
const updates = {};
|
|
1723
|
+
if (req.body.role) updates.role = req.body.role;
|
|
1724
|
+
if (req.body.password) updates.passwordHash = await hashPassword(req.body.password);
|
|
1725
|
+
if (updates.role === "viewer" && user.role === "admin") {
|
|
1726
|
+
const allAdmins = (await db.getAllUsers()).filter((u) => u.role === "admin");
|
|
1727
|
+
if (allAdmins.length <= 1) {
|
|
1728
|
+
res.status(400).json({ error: "Cannot demote the last admin" });
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
await db.updateUser(id, updates);
|
|
1733
|
+
const updated = await db.getUserById(id);
|
|
1734
|
+
res.json({ id: updated.id, username: updated.username, role: updated.role });
|
|
1735
|
+
});
|
|
1736
|
+
router.delete("/api/accounts/:id", requireAuth, requireAdmin, async (req, res) => {
|
|
1737
|
+
const { id } = req.params;
|
|
1738
|
+
const actor = req.user;
|
|
1739
|
+
if (actor.sub === id) {
|
|
1740
|
+
res.status(400).json({ error: "Cannot delete your own account" });
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
const user = await db.getUserById(id);
|
|
1744
|
+
if (!user) {
|
|
1745
|
+
res.status(404).json({ error: "User not found" });
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
if (user.role === "admin") {
|
|
1749
|
+
const allAdmins = (await db.getAllUsers()).filter((u) => u.role === "admin");
|
|
1750
|
+
if (allAdmins.length <= 1) {
|
|
1751
|
+
res.status(400).json({ error: "Cannot delete the last admin" });
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
await db.deleteUser(id);
|
|
1756
|
+
res.json({ success: true });
|
|
1757
|
+
});
|
|
1758
|
+
return router;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// cli/env-routes.ts
|
|
1762
|
+
init_esm_shims();
|
|
1763
|
+
import { Router as Router3 } from "express";
|
|
1764
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync } from "fs";
|
|
1765
|
+
import { join as join2 } from "path";
|
|
1766
|
+
|
|
1767
|
+
// cli/env-config.ts
|
|
1768
|
+
init_esm_shims();
|
|
1769
|
+
var ENV_VARIABLES = [
|
|
1770
|
+
// ============================================================================
|
|
1771
|
+
// LLM CONFIGURATION
|
|
1772
|
+
// ============================================================================
|
|
1773
|
+
{
|
|
1774
|
+
key: "LLM_PROVIDER",
|
|
1775
|
+
type: "select",
|
|
1776
|
+
category: "llm",
|
|
1777
|
+
required: true,
|
|
1778
|
+
description: "LLM provider to use for AI operations",
|
|
1779
|
+
options: ["openai", "anthropic", "ollama"],
|
|
1780
|
+
placeholder: "openai",
|
|
1781
|
+
restartRequired: true
|
|
1782
|
+
},
|
|
1783
|
+
{
|
|
1784
|
+
key: "OPENAI_API_KEY",
|
|
1785
|
+
type: "password",
|
|
1786
|
+
category: "llm",
|
|
1787
|
+
required: false,
|
|
1788
|
+
description: "OpenAI API key (required if LLM_PROVIDER=openai)",
|
|
1789
|
+
placeholder: "sk-...",
|
|
1790
|
+
sensitive: true,
|
|
1791
|
+
testable: true,
|
|
1792
|
+
validation: (value) => {
|
|
1793
|
+
if (!value) return { valid: true };
|
|
1794
|
+
if (!value.startsWith("sk-")) {
|
|
1795
|
+
return { valid: false, error: 'OpenAI API key must start with "sk-"' };
|
|
1796
|
+
}
|
|
1797
|
+
if (value.length < 20) {
|
|
1798
|
+
return { valid: false, error: "API key seems too short" };
|
|
1799
|
+
}
|
|
1800
|
+
return { valid: true };
|
|
1801
|
+
},
|
|
1802
|
+
restartRequired: true
|
|
1803
|
+
},
|
|
1804
|
+
{
|
|
1805
|
+
key: "ANTHROPIC_API_KEY",
|
|
1806
|
+
type: "password",
|
|
1807
|
+
category: "llm",
|
|
1808
|
+
required: false,
|
|
1809
|
+
description: "Anthropic API key (required if LLM_PROVIDER=anthropic)",
|
|
1810
|
+
placeholder: "sk-ant-...",
|
|
1811
|
+
sensitive: true,
|
|
1812
|
+
testable: true,
|
|
1813
|
+
validation: (value) => {
|
|
1814
|
+
if (!value) return { valid: true };
|
|
1815
|
+
if (!value.startsWith("sk-ant-")) {
|
|
1816
|
+
return { valid: false, error: 'Anthropic API key must start with "sk-ant-"' };
|
|
1817
|
+
}
|
|
1818
|
+
return { valid: true };
|
|
1819
|
+
},
|
|
1820
|
+
restartRequired: true
|
|
1821
|
+
},
|
|
1822
|
+
{
|
|
1823
|
+
key: "OLLAMA_BASE_URL",
|
|
1824
|
+
type: "url",
|
|
1825
|
+
category: "llm",
|
|
1826
|
+
required: false,
|
|
1827
|
+
description: "Ollama server URL (required if LLM_PROVIDER=ollama)",
|
|
1828
|
+
placeholder: "http://localhost:11434",
|
|
1829
|
+
testable: true,
|
|
1830
|
+
validation: (value) => {
|
|
1831
|
+
if (!value) return { valid: true };
|
|
1832
|
+
try {
|
|
1833
|
+
new URL(value);
|
|
1834
|
+
return { valid: true };
|
|
1835
|
+
} catch {
|
|
1836
|
+
return { valid: false, error: "Invalid URL format" };
|
|
1837
|
+
}
|
|
1838
|
+
},
|
|
1839
|
+
restartRequired: true
|
|
1840
|
+
},
|
|
1841
|
+
{
|
|
1842
|
+
key: "LLM_MODEL",
|
|
1843
|
+
type: "text",
|
|
1844
|
+
category: "llm",
|
|
1845
|
+
required: false,
|
|
1846
|
+
description: "LLM model to use (e.g., gpt-4, claude-3-opus, llama2)",
|
|
1847
|
+
placeholder: "gpt-4",
|
|
1848
|
+
restartRequired: true
|
|
1849
|
+
},
|
|
1850
|
+
// ============================================================================
|
|
1851
|
+
// SECURITY
|
|
1852
|
+
// ============================================================================
|
|
1853
|
+
{
|
|
1854
|
+
key: "OPENQA_JWT_SECRET",
|
|
1855
|
+
type: "password",
|
|
1856
|
+
category: "security",
|
|
1857
|
+
required: true,
|
|
1858
|
+
description: "Secret key for JWT token signing (min 32 characters)",
|
|
1859
|
+
placeholder: "Generate with: openssl rand -hex 32",
|
|
1860
|
+
sensitive: true,
|
|
1861
|
+
validation: (value) => {
|
|
1862
|
+
if (!value) return { valid: false, error: "JWT secret is required" };
|
|
1863
|
+
if (value.length < 32) {
|
|
1864
|
+
return { valid: false, error: "JWT secret must be at least 32 characters" };
|
|
1865
|
+
}
|
|
1866
|
+
return { valid: true };
|
|
1867
|
+
},
|
|
1868
|
+
restartRequired: true
|
|
1869
|
+
},
|
|
1870
|
+
{
|
|
1871
|
+
key: "OPENQA_AUTH_DISABLED",
|
|
1872
|
+
type: "boolean",
|
|
1873
|
+
category: "security",
|
|
1874
|
+
required: false,
|
|
1875
|
+
description: "\u26A0\uFE0F DANGER: Disable authentication (NEVER use in production!)",
|
|
1876
|
+
placeholder: "false",
|
|
1877
|
+
validation: (value) => {
|
|
1878
|
+
if (value === "true" && process.env.NODE_ENV === "production") {
|
|
1879
|
+
return { valid: false, error: "Cannot disable auth in production!" };
|
|
1880
|
+
}
|
|
1881
|
+
return { valid: true };
|
|
1882
|
+
},
|
|
1883
|
+
restartRequired: true
|
|
1884
|
+
},
|
|
1885
|
+
{
|
|
1886
|
+
key: "NODE_ENV",
|
|
1887
|
+
type: "select",
|
|
1888
|
+
category: "security",
|
|
1889
|
+
required: false,
|
|
1890
|
+
description: "Node environment (production enables security features)",
|
|
1891
|
+
options: ["development", "production", "test"],
|
|
1892
|
+
placeholder: "production",
|
|
1893
|
+
restartRequired: true
|
|
1894
|
+
},
|
|
1895
|
+
// ============================================================================
|
|
1896
|
+
// TARGET APPLICATION
|
|
1897
|
+
// ============================================================================
|
|
1898
|
+
{
|
|
1899
|
+
key: "SAAS_URL",
|
|
1900
|
+
type: "url",
|
|
1901
|
+
category: "target",
|
|
1902
|
+
required: true,
|
|
1903
|
+
description: "URL of the application to test",
|
|
1904
|
+
placeholder: "https://your-app.com",
|
|
1905
|
+
testable: true,
|
|
1906
|
+
validation: (value) => {
|
|
1907
|
+
if (!value) return { valid: false, error: "Target URL is required" };
|
|
1908
|
+
try {
|
|
1909
|
+
const url = new URL(value);
|
|
1910
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
1911
|
+
return { valid: false, error: "URL must use http or https protocol" };
|
|
1912
|
+
}
|
|
1913
|
+
return { valid: true };
|
|
1914
|
+
} catch {
|
|
1915
|
+
return { valid: false, error: "Invalid URL format" };
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
},
|
|
1919
|
+
{
|
|
1920
|
+
key: "SAAS_AUTH_TYPE",
|
|
1921
|
+
type: "select",
|
|
1922
|
+
category: "target",
|
|
1923
|
+
required: false,
|
|
1924
|
+
description: "Authentication type for target application",
|
|
1925
|
+
options: ["none", "basic", "session"],
|
|
1926
|
+
placeholder: "none"
|
|
1927
|
+
},
|
|
1928
|
+
{
|
|
1929
|
+
key: "SAAS_USERNAME",
|
|
1930
|
+
type: "text",
|
|
1931
|
+
category: "target",
|
|
1932
|
+
required: false,
|
|
1933
|
+
description: "Username for target application authentication",
|
|
1934
|
+
placeholder: "test@example.com"
|
|
1935
|
+
},
|
|
1936
|
+
{
|
|
1937
|
+
key: "SAAS_PASSWORD",
|
|
1938
|
+
type: "password",
|
|
1939
|
+
category: "target",
|
|
1940
|
+
required: false,
|
|
1941
|
+
description: "Password for target application authentication",
|
|
1942
|
+
placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
|
|
1943
|
+
sensitive: true
|
|
1944
|
+
},
|
|
1945
|
+
// ============================================================================
|
|
1946
|
+
// GITHUB INTEGRATION
|
|
1947
|
+
// ============================================================================
|
|
1948
|
+
{
|
|
1949
|
+
key: "GITHUB_TOKEN",
|
|
1950
|
+
type: "password",
|
|
1951
|
+
category: "github",
|
|
1952
|
+
required: false,
|
|
1953
|
+
description: "GitHub personal access token for issue creation",
|
|
1954
|
+
placeholder: "ghp_...",
|
|
1955
|
+
sensitive: true,
|
|
1956
|
+
testable: true,
|
|
1957
|
+
validation: (value) => {
|
|
1958
|
+
if (!value) return { valid: true };
|
|
1959
|
+
if (!value.startsWith("ghp_") && !value.startsWith("github_pat_")) {
|
|
1960
|
+
return { valid: false, error: 'GitHub token must start with "ghp_" or "github_pat_"' };
|
|
1961
|
+
}
|
|
1962
|
+
return { valid: true };
|
|
1963
|
+
}
|
|
1964
|
+
},
|
|
1965
|
+
{
|
|
1966
|
+
key: "GITHUB_OWNER",
|
|
1967
|
+
type: "text",
|
|
1968
|
+
category: "github",
|
|
1969
|
+
required: false,
|
|
1970
|
+
description: "GitHub repository owner/organization",
|
|
1971
|
+
placeholder: "your-username"
|
|
1972
|
+
},
|
|
1973
|
+
{
|
|
1974
|
+
key: "GITHUB_REPO",
|
|
1975
|
+
type: "text",
|
|
1976
|
+
category: "github",
|
|
1977
|
+
required: false,
|
|
1978
|
+
description: "GitHub repository name",
|
|
1979
|
+
placeholder: "your-repo"
|
|
1980
|
+
},
|
|
1981
|
+
{
|
|
1982
|
+
key: "GITHUB_BRANCH",
|
|
1983
|
+
type: "text",
|
|
1984
|
+
category: "github",
|
|
1985
|
+
required: false,
|
|
1986
|
+
description: "GitHub branch to monitor",
|
|
1987
|
+
placeholder: "main"
|
|
1988
|
+
},
|
|
1989
|
+
// ============================================================================
|
|
1990
|
+
// WEB SERVER
|
|
1991
|
+
// ============================================================================
|
|
1992
|
+
{
|
|
1993
|
+
key: "WEB_PORT",
|
|
1994
|
+
type: "number",
|
|
1995
|
+
category: "web",
|
|
1996
|
+
required: false,
|
|
1997
|
+
description: "Port for web server",
|
|
1998
|
+
placeholder: "4242",
|
|
1999
|
+
validation: (value) => {
|
|
2000
|
+
if (!value) return { valid: true };
|
|
2001
|
+
const port = parseInt(value, 10);
|
|
2002
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
2003
|
+
return { valid: false, error: "Port must be between 1 and 65535" };
|
|
2004
|
+
}
|
|
2005
|
+
return { valid: true };
|
|
2006
|
+
},
|
|
2007
|
+
restartRequired: true
|
|
2008
|
+
},
|
|
2009
|
+
{
|
|
2010
|
+
key: "WEB_HOST",
|
|
2011
|
+
type: "text",
|
|
2012
|
+
category: "web",
|
|
2013
|
+
required: false,
|
|
2014
|
+
description: "Host to bind web server (0.0.0.0 for all interfaces)",
|
|
2015
|
+
placeholder: "0.0.0.0",
|
|
2016
|
+
restartRequired: true
|
|
2017
|
+
},
|
|
2018
|
+
{
|
|
2019
|
+
key: "CORS_ORIGINS",
|
|
2020
|
+
type: "text",
|
|
2021
|
+
category: "web",
|
|
2022
|
+
required: false,
|
|
2023
|
+
description: "Allowed CORS origins (comma-separated)",
|
|
2024
|
+
placeholder: "https://your-domain.com,https://app.example.com",
|
|
2025
|
+
restartRequired: true
|
|
2026
|
+
},
|
|
2027
|
+
// ============================================================================
|
|
2028
|
+
// AGENT CONFIGURATION
|
|
2029
|
+
// ============================================================================
|
|
2030
|
+
{
|
|
2031
|
+
key: "AGENT_AUTO_START",
|
|
2032
|
+
type: "boolean",
|
|
2033
|
+
category: "agent",
|
|
2034
|
+
required: false,
|
|
2035
|
+
description: "Auto-start agent on server launch",
|
|
2036
|
+
placeholder: "false"
|
|
2037
|
+
},
|
|
2038
|
+
{
|
|
2039
|
+
key: "AGENT_INTERVAL_MS",
|
|
2040
|
+
type: "number",
|
|
2041
|
+
category: "agent",
|
|
2042
|
+
required: false,
|
|
2043
|
+
description: "Agent run interval in milliseconds (1 hour = 3600000)",
|
|
2044
|
+
placeholder: "3600000",
|
|
2045
|
+
validation: (value) => {
|
|
2046
|
+
if (!value) return { valid: true };
|
|
2047
|
+
const interval = parseInt(value, 10);
|
|
2048
|
+
if (isNaN(interval) || interval < 6e4) {
|
|
2049
|
+
return { valid: false, error: "Interval must be at least 60000ms (1 minute)" };
|
|
2050
|
+
}
|
|
2051
|
+
return { valid: true };
|
|
2052
|
+
}
|
|
2053
|
+
},
|
|
2054
|
+
{
|
|
2055
|
+
key: "AGENT_MAX_ITERATIONS",
|
|
2056
|
+
type: "number",
|
|
2057
|
+
category: "agent",
|
|
2058
|
+
required: false,
|
|
2059
|
+
description: "Maximum iterations per agent session",
|
|
2060
|
+
placeholder: "20",
|
|
2061
|
+
validation: (value) => {
|
|
2062
|
+
if (!value) return { valid: true };
|
|
2063
|
+
const max = parseInt(value, 10);
|
|
2064
|
+
if (isNaN(max) || max < 1 || max > 1e3) {
|
|
2065
|
+
return { valid: false, error: "Max iterations must be between 1 and 1000" };
|
|
2066
|
+
}
|
|
2067
|
+
return { valid: true };
|
|
2068
|
+
}
|
|
2069
|
+
},
|
|
2070
|
+
{
|
|
2071
|
+
key: "GIT_LISTENER_ENABLED",
|
|
2072
|
+
type: "boolean",
|
|
2073
|
+
category: "agent",
|
|
2074
|
+
required: false,
|
|
2075
|
+
description: "Enable git merge/pipeline detection",
|
|
2076
|
+
placeholder: "true"
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
key: "GIT_POLL_INTERVAL_MS",
|
|
2080
|
+
type: "number",
|
|
2081
|
+
category: "agent",
|
|
2082
|
+
required: false,
|
|
2083
|
+
description: "Git polling interval in milliseconds",
|
|
2084
|
+
placeholder: "60000"
|
|
2085
|
+
},
|
|
2086
|
+
// ============================================================================
|
|
2087
|
+
// DATABASE
|
|
2088
|
+
// ============================================================================
|
|
2089
|
+
{
|
|
2090
|
+
key: "DB_PATH",
|
|
2091
|
+
type: "text",
|
|
2092
|
+
category: "database",
|
|
2093
|
+
required: false,
|
|
2094
|
+
description: "Path to SQLite database file",
|
|
2095
|
+
placeholder: "./data/openqa.db",
|
|
2096
|
+
restartRequired: true
|
|
2097
|
+
},
|
|
2098
|
+
// ============================================================================
|
|
2099
|
+
// NOTIFICATIONS
|
|
2100
|
+
// ============================================================================
|
|
2101
|
+
{
|
|
2102
|
+
key: "SLACK_WEBHOOK_URL",
|
|
2103
|
+
type: "url",
|
|
2104
|
+
category: "notifications",
|
|
2105
|
+
required: false,
|
|
2106
|
+
description: "Slack webhook URL for notifications",
|
|
2107
|
+
placeholder: "https://hooks.slack.com/services/...",
|
|
2108
|
+
sensitive: true,
|
|
2109
|
+
testable: true,
|
|
2110
|
+
validation: (value) => {
|
|
2111
|
+
if (!value) return { valid: true };
|
|
2112
|
+
if (!value.startsWith("https://hooks.slack.com/")) {
|
|
2113
|
+
return { valid: false, error: "Invalid Slack webhook URL" };
|
|
2114
|
+
}
|
|
2115
|
+
return { valid: true };
|
|
2116
|
+
}
|
|
2117
|
+
},
|
|
2118
|
+
{
|
|
2119
|
+
key: "DISCORD_WEBHOOK_URL",
|
|
2120
|
+
type: "url",
|
|
2121
|
+
category: "notifications",
|
|
2122
|
+
required: false,
|
|
2123
|
+
description: "Discord webhook URL for notifications",
|
|
2124
|
+
placeholder: "https://discord.com/api/webhooks/...",
|
|
2125
|
+
sensitive: true,
|
|
2126
|
+
testable: true,
|
|
2127
|
+
validation: (value) => {
|
|
2128
|
+
if (!value) return { valid: true };
|
|
2129
|
+
if (!value.startsWith("https://discord.com/api/webhooks/")) {
|
|
2130
|
+
return { valid: false, error: "Invalid Discord webhook URL" };
|
|
2131
|
+
}
|
|
2132
|
+
return { valid: true };
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
];
|
|
2136
|
+
function getEnvVariable(key) {
|
|
2137
|
+
return ENV_VARIABLES.find((v) => v.key === key);
|
|
2138
|
+
}
|
|
2139
|
+
function validateEnvValue(key, value) {
|
|
2140
|
+
const envVar = getEnvVariable(key);
|
|
2141
|
+
if (!envVar) return { valid: false, error: "Unknown environment variable" };
|
|
2142
|
+
if (envVar.required && !value) {
|
|
2143
|
+
return { valid: false, error: "This field is required" };
|
|
2144
|
+
}
|
|
2145
|
+
if (envVar.validation) {
|
|
2146
|
+
return envVar.validation(value);
|
|
2147
|
+
}
|
|
2148
|
+
return { valid: true };
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
// cli/env-routes.ts
|
|
2152
|
+
function createEnvRouter() {
|
|
2153
|
+
const router = Router3();
|
|
2154
|
+
const ENV_FILE_PATH = join2(process.cwd(), ".env");
|
|
2155
|
+
function readEnvFile() {
|
|
2156
|
+
if (!existsSync(ENV_FILE_PATH)) {
|
|
2157
|
+
return {};
|
|
2158
|
+
}
|
|
2159
|
+
const content = readFileSync2(ENV_FILE_PATH, "utf-8");
|
|
2160
|
+
const env = {};
|
|
2161
|
+
content.split("\n").forEach((line) => {
|
|
2162
|
+
line = line.trim();
|
|
2163
|
+
if (!line || line.startsWith("#")) return;
|
|
2164
|
+
const match = line.match(/^([^=]+)=(.*)$/);
|
|
2165
|
+
if (match) {
|
|
2166
|
+
const [, key, value] = match;
|
|
2167
|
+
env[key.trim()] = value.trim().replace(/^["']|["']$/g, "");
|
|
2168
|
+
}
|
|
2169
|
+
});
|
|
2170
|
+
return env;
|
|
2171
|
+
}
|
|
2172
|
+
function writeEnvFile(env) {
|
|
2173
|
+
const lines = [
|
|
2174
|
+
"# OpenQA Environment Configuration",
|
|
2175
|
+
"# Auto-generated by OpenQA Dashboard",
|
|
2176
|
+
"# Last updated: " + (/* @__PURE__ */ new Date()).toISOString(),
|
|
2177
|
+
""
|
|
2178
|
+
];
|
|
2179
|
+
const categories = {};
|
|
2180
|
+
ENV_VARIABLES.forEach((v) => {
|
|
2181
|
+
if (!categories[v.category]) {
|
|
2182
|
+
categories[v.category] = [];
|
|
2183
|
+
}
|
|
2184
|
+
const value = env[v.key] || "";
|
|
2185
|
+
if (value || v.required) {
|
|
2186
|
+
categories[v.category].push(`${v.key}=${value}`);
|
|
2187
|
+
}
|
|
2188
|
+
});
|
|
2189
|
+
const categoryNames = {
|
|
2190
|
+
llm: "LLM CONFIGURATION",
|
|
2191
|
+
security: "SECURITY",
|
|
2192
|
+
target: "TARGET APPLICATION",
|
|
2193
|
+
github: "GITHUB INTEGRATION",
|
|
2194
|
+
web: "WEB SERVER",
|
|
2195
|
+
agent: "AGENT CONFIGURATION",
|
|
2196
|
+
database: "DATABASE",
|
|
2197
|
+
notifications: "NOTIFICATIONS"
|
|
2198
|
+
};
|
|
2199
|
+
Object.entries(categories).forEach(([category, vars]) => {
|
|
2200
|
+
if (vars.length > 0) {
|
|
2201
|
+
lines.push("# " + "=".repeat(76));
|
|
2202
|
+
lines.push(`# ${categoryNames[category] || category.toUpperCase()}`);
|
|
2203
|
+
lines.push("# " + "=".repeat(76));
|
|
2204
|
+
lines.push(...vars);
|
|
2205
|
+
lines.push("");
|
|
2206
|
+
}
|
|
2207
|
+
});
|
|
2208
|
+
writeFileSync2(ENV_FILE_PATH, lines.join("\n"));
|
|
2209
|
+
}
|
|
2210
|
+
router.get("/api/env", requireAuth, requireAdmin, (_req, res) => {
|
|
2211
|
+
try {
|
|
2212
|
+
const envFile = readEnvFile();
|
|
2213
|
+
const processEnv = process.env;
|
|
2214
|
+
const variables = ENV_VARIABLES.map((v) => ({
|
|
2215
|
+
key: v.key,
|
|
2216
|
+
value: envFile[v.key] || processEnv[v.key] || "",
|
|
2217
|
+
type: v.type,
|
|
2218
|
+
category: v.category,
|
|
2219
|
+
required: v.required,
|
|
2220
|
+
description: v.description,
|
|
2221
|
+
placeholder: v.placeholder,
|
|
2222
|
+
options: v.options,
|
|
2223
|
+
sensitive: v.sensitive,
|
|
2224
|
+
testable: v.testable,
|
|
2225
|
+
restartRequired: v.restartRequired,
|
|
2226
|
+
// Mask sensitive values
|
|
2227
|
+
displayValue: v.sensitive && (envFile[v.key] || processEnv[v.key]) ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : envFile[v.key] || processEnv[v.key] || ""
|
|
2228
|
+
}));
|
|
2229
|
+
res.json({
|
|
2230
|
+
variables,
|
|
2231
|
+
envFileExists: existsSync(ENV_FILE_PATH),
|
|
2232
|
+
lastModified: existsSync(ENV_FILE_PATH) ? new Date(readFileSync2(ENV_FILE_PATH, "utf-8").match(/Last updated: (.+)/)?.[1] || 0).toISOString() : null
|
|
2233
|
+
});
|
|
2234
|
+
} catch (error) {
|
|
2235
|
+
res.status(500).json({
|
|
2236
|
+
error: "Failed to read environment variables",
|
|
2237
|
+
details: error instanceof Error ? error.message : String(error)
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
router.get("/api/env/:key", requireAuth, requireAdmin, (req, res) => {
|
|
2242
|
+
try {
|
|
2243
|
+
const { key } = req.params;
|
|
2244
|
+
const envVar = ENV_VARIABLES.find((v) => v.key === key);
|
|
2245
|
+
if (!envVar) {
|
|
2246
|
+
res.status(404).json({ error: "Environment variable not found" });
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
const envFile = readEnvFile();
|
|
2250
|
+
const value = envFile[key] || process.env[key] || "";
|
|
2251
|
+
res.json({
|
|
2252
|
+
...envVar,
|
|
2253
|
+
value,
|
|
2254
|
+
displayValue: envVar.sensitive && value ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : value
|
|
2255
|
+
});
|
|
2256
|
+
} catch (error) {
|
|
2257
|
+
res.status(500).json({
|
|
2258
|
+
error: "Failed to read environment variable",
|
|
2259
|
+
details: error instanceof Error ? error.message : String(error)
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
});
|
|
2263
|
+
router.put("/api/env/:key", requireAuth, requireAdmin, (req, res) => {
|
|
2264
|
+
try {
|
|
2265
|
+
const { key } = req.params;
|
|
2266
|
+
const { value } = req.body;
|
|
2267
|
+
const validation = validateEnvValue(key, value);
|
|
2268
|
+
if (!validation.valid) {
|
|
2269
|
+
res.status(400).json({ error: validation.error });
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
const env = readEnvFile();
|
|
2273
|
+
if (value === "" || value === null || value === void 0) {
|
|
2274
|
+
delete env[key];
|
|
2275
|
+
} else {
|
|
2276
|
+
env[key] = value;
|
|
2277
|
+
}
|
|
2278
|
+
writeEnvFile(env);
|
|
2279
|
+
if (value) {
|
|
2280
|
+
process.env[key] = value;
|
|
2281
|
+
} else {
|
|
2282
|
+
delete process.env[key];
|
|
2283
|
+
}
|
|
2284
|
+
const envVar = ENV_VARIABLES.find((v) => v.key === key);
|
|
2285
|
+
res.json({
|
|
2286
|
+
success: true,
|
|
2287
|
+
key,
|
|
2288
|
+
value: envVar?.sensitive ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : value,
|
|
2289
|
+
restartRequired: envVar?.restartRequired || false
|
|
2290
|
+
});
|
|
2291
|
+
} catch (error) {
|
|
2292
|
+
res.status(500).json({
|
|
2293
|
+
error: "Failed to update environment variable",
|
|
2294
|
+
details: error instanceof Error ? error.message : String(error)
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
});
|
|
2298
|
+
router.post("/api/env/bulk", requireAuth, requireAdmin, (req, res) => {
|
|
2299
|
+
try {
|
|
2300
|
+
const { variables } = req.body;
|
|
2301
|
+
if (!variables || typeof variables !== "object") {
|
|
2302
|
+
res.status(400).json({ error: "Invalid request body" });
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
const errors = {};
|
|
2306
|
+
Object.entries(variables).forEach(([key, value]) => {
|
|
2307
|
+
const validation = validateEnvValue(key, value);
|
|
2308
|
+
if (!validation.valid) {
|
|
2309
|
+
errors[key] = validation.error || "Invalid value";
|
|
2310
|
+
}
|
|
2311
|
+
});
|
|
2312
|
+
if (Object.keys(errors).length > 0) {
|
|
2313
|
+
res.status(400).json({ error: "Validation failed", errors });
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
const env = readEnvFile();
|
|
2317
|
+
Object.entries(variables).forEach(([key, value]) => {
|
|
2318
|
+
if (value === "" || value === null || value === void 0) {
|
|
2319
|
+
delete env[key];
|
|
2320
|
+
delete process.env[key];
|
|
2321
|
+
} else {
|
|
2322
|
+
env[key] = value;
|
|
2323
|
+
process.env[key] = value;
|
|
2324
|
+
}
|
|
2325
|
+
});
|
|
2326
|
+
writeEnvFile(env);
|
|
2327
|
+
const restartRequired = Object.keys(variables).some((key) => {
|
|
2328
|
+
const envVar = ENV_VARIABLES.find((v) => v.key === key);
|
|
2329
|
+
return envVar?.restartRequired;
|
|
2330
|
+
});
|
|
2331
|
+
res.json({
|
|
2332
|
+
success: true,
|
|
2333
|
+
updated: Object.keys(variables).length,
|
|
2334
|
+
restartRequired
|
|
2335
|
+
});
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
res.status(500).json({
|
|
2338
|
+
error: "Failed to update environment variables",
|
|
2339
|
+
details: error instanceof Error ? error.message : String(error)
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
router.post("/api/env/test/:key", requireAuth, requireAdmin, async (req, res) => {
|
|
2344
|
+
try {
|
|
2345
|
+
const { key } = req.params;
|
|
2346
|
+
const { value } = req.body;
|
|
2347
|
+
const envVar = ENV_VARIABLES.find((v) => v.key === key);
|
|
2348
|
+
if (!envVar || !envVar.testable) {
|
|
2349
|
+
res.status(400).json({ error: "This variable cannot be tested" });
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
let testResult;
|
|
2353
|
+
switch (key) {
|
|
2354
|
+
case "OPENAI_API_KEY":
|
|
2355
|
+
testResult = await testOpenAIKey(value);
|
|
2356
|
+
break;
|
|
2357
|
+
case "ANTHROPIC_API_KEY":
|
|
2358
|
+
testResult = await testAnthropicKey(value);
|
|
2359
|
+
break;
|
|
2360
|
+
case "OLLAMA_BASE_URL":
|
|
2361
|
+
testResult = await testOllamaURL(value);
|
|
2362
|
+
break;
|
|
2363
|
+
case "GITHUB_TOKEN":
|
|
2364
|
+
testResult = await testGitHubToken(value);
|
|
2365
|
+
break;
|
|
2366
|
+
case "SAAS_URL":
|
|
2367
|
+
testResult = await testURL(value);
|
|
2368
|
+
break;
|
|
2369
|
+
case "SLACK_WEBHOOK_URL":
|
|
2370
|
+
testResult = await testSlackWebhook(value);
|
|
2371
|
+
break;
|
|
2372
|
+
case "DISCORD_WEBHOOK_URL":
|
|
2373
|
+
testResult = await testDiscordWebhook(value);
|
|
2374
|
+
break;
|
|
2375
|
+
default:
|
|
2376
|
+
testResult = { success: false, message: "Test not implemented for this variable" };
|
|
2377
|
+
}
|
|
2378
|
+
res.json(testResult);
|
|
2379
|
+
} catch (error) {
|
|
2380
|
+
res.status(500).json({
|
|
2381
|
+
success: false,
|
|
2382
|
+
message: error instanceof Error ? error.message : "Test failed"
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
});
|
|
2386
|
+
router.post("/api/env/generate/:key", requireAuth, requireAdmin, (req, res) => {
|
|
2387
|
+
try {
|
|
2388
|
+
const { key } = req.params;
|
|
2389
|
+
let generated;
|
|
2390
|
+
switch (key) {
|
|
2391
|
+
case "OPENQA_JWT_SECRET":
|
|
2392
|
+
generated = Array.from(
|
|
2393
|
+
{ length: 64 },
|
|
2394
|
+
() => Math.floor(Math.random() * 16).toString(16)
|
|
2395
|
+
).join("");
|
|
2396
|
+
break;
|
|
2397
|
+
default:
|
|
2398
|
+
res.status(400).json({ error: "Generation not supported for this variable" });
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
res.json({ success: true, value: generated });
|
|
2402
|
+
} catch (error) {
|
|
2403
|
+
res.status(500).json({
|
|
2404
|
+
error: "Failed to generate value",
|
|
2405
|
+
details: error instanceof Error ? error.message : String(error)
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
});
|
|
2409
|
+
return router;
|
|
2410
|
+
}
|
|
2411
|
+
async function testOpenAIKey(apiKey) {
|
|
2412
|
+
try {
|
|
2413
|
+
const response = await fetch("https://api.openai.com/v1/models", {
|
|
2414
|
+
headers: { "Authorization": `Bearer ${apiKey}` }
|
|
2415
|
+
});
|
|
2416
|
+
if (response.ok) {
|
|
2417
|
+
return { success: true, message: "OpenAI API key is valid" };
|
|
2418
|
+
}
|
|
2419
|
+
return { success: false, message: "Invalid OpenAI API key" };
|
|
2420
|
+
} catch {
|
|
2421
|
+
return { success: false, message: "Failed to connect to OpenAI API" };
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
async function testAnthropicKey(apiKey) {
|
|
2425
|
+
try {
|
|
2426
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
2427
|
+
method: "POST",
|
|
2428
|
+
headers: {
|
|
2429
|
+
"x-api-key": apiKey,
|
|
2430
|
+
"anthropic-version": "2023-06-01",
|
|
2431
|
+
"content-type": "application/json"
|
|
2432
|
+
},
|
|
2433
|
+
body: JSON.stringify({
|
|
2434
|
+
model: "claude-3-haiku-20240307",
|
|
2435
|
+
max_tokens: 1,
|
|
2436
|
+
messages: [{ role: "user", content: "test" }]
|
|
2437
|
+
})
|
|
2438
|
+
});
|
|
2439
|
+
if (response.status === 200 || response.status === 400) {
|
|
2440
|
+
return { success: true, message: "Anthropic API key is valid" };
|
|
2441
|
+
}
|
|
2442
|
+
return { success: false, message: "Invalid Anthropic API key" };
|
|
2443
|
+
} catch {
|
|
2444
|
+
return { success: false, message: "Failed to connect to Anthropic API" };
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
async function testOllamaURL(url) {
|
|
2448
|
+
try {
|
|
2449
|
+
const response = await fetch(`${url}/api/tags`);
|
|
2450
|
+
if (response.ok) {
|
|
2451
|
+
return { success: true, message: "Ollama server is accessible" };
|
|
2452
|
+
}
|
|
2453
|
+
return { success: false, message: "Ollama server returned an error" };
|
|
2454
|
+
} catch {
|
|
2455
|
+
return { success: false, message: "Cannot connect to Ollama server" };
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
async function testGitHubToken(token) {
|
|
2459
|
+
try {
|
|
2460
|
+
const response = await fetch("https://api.github.com/user", {
|
|
2461
|
+
headers: { "Authorization": `token ${token}` }
|
|
2462
|
+
});
|
|
2463
|
+
if (response.ok) {
|
|
2464
|
+
const data = await response.json();
|
|
2465
|
+
return { success: true, message: `GitHub token is valid (user: ${data.login})` };
|
|
2466
|
+
}
|
|
2467
|
+
return { success: false, message: "Invalid GitHub token" };
|
|
2468
|
+
} catch {
|
|
2469
|
+
return { success: false, message: "Failed to connect to GitHub API" };
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
async function testURL(url) {
|
|
2473
|
+
try {
|
|
2474
|
+
const response = await fetch(url, { method: "HEAD" });
|
|
2475
|
+
if (response.ok) {
|
|
2476
|
+
return { success: true, message: "URL is accessible" };
|
|
2477
|
+
}
|
|
2478
|
+
return { success: false, message: `URL returned status ${response.status}` };
|
|
2479
|
+
} catch {
|
|
2480
|
+
return { success: false, message: "Cannot connect to URL" };
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
async function testSlackWebhook(url) {
|
|
2484
|
+
try {
|
|
2485
|
+
const response = await fetch(url, {
|
|
2486
|
+
method: "POST",
|
|
2487
|
+
headers: { "Content-Type": "application/json" },
|
|
2488
|
+
body: JSON.stringify({ text: "OpenQA webhook test" })
|
|
2489
|
+
});
|
|
2490
|
+
if (response.ok) {
|
|
2491
|
+
return { success: true, message: "Slack webhook is valid" };
|
|
2492
|
+
}
|
|
2493
|
+
return { success: false, message: "Invalid Slack webhook" };
|
|
2494
|
+
} catch {
|
|
2495
|
+
return { success: false, message: "Failed to connect to Slack webhook" };
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
async function testDiscordWebhook(url) {
|
|
2499
|
+
try {
|
|
2500
|
+
const response = await fetch(url, {
|
|
2501
|
+
method: "POST",
|
|
2502
|
+
headers: { "Content-Type": "application/json" },
|
|
2503
|
+
body: JSON.stringify({ content: "OpenQA webhook test" })
|
|
2504
|
+
});
|
|
2505
|
+
if (response.ok || response.status === 204) {
|
|
2506
|
+
return { success: true, message: "Discord webhook is valid" };
|
|
2507
|
+
}
|
|
2508
|
+
return { success: false, message: "Invalid Discord webhook" };
|
|
2509
|
+
} catch {
|
|
2510
|
+
return { success: false, message: "Failed to connect to Discord webhook" };
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
|
|
1471
2514
|
// cli/dashboard.html.ts
|
|
1472
2515
|
init_esm_shims();
|
|
1473
2516
|
function getDashboardHTML() {
|
|
@@ -1546,7 +2589,7 @@ function getDashboardHTML() {
|
|
|
1546
2589
|
.logo-mark {
|
|
1547
2590
|
width: 34px;
|
|
1548
2591
|
height: 34px;
|
|
1549
|
-
background:
|
|
2592
|
+
background: transparent;
|
|
1550
2593
|
border-radius: 8px;
|
|
1551
2594
|
display: grid;
|
|
1552
2595
|
place-items: center;
|
|
@@ -2168,7 +3211,9 @@ function getDashboardHTML() {
|
|
|
2168
3211
|
<!-- Sidebar -->
|
|
2169
3212
|
<aside>
|
|
2170
3213
|
<div class="logo">
|
|
2171
|
-
<div class="logo-mark"
|
|
3214
|
+
<div class="logo-mark">
|
|
3215
|
+
<img src="https://openqa.orkajs.com/_next/image?url=https%3A%2F%2Forkajs.com%2Floutre-orka-qa.png&w=256&q=75" alt="OpenQA Logo" style="width: 40px; height: 40px;">
|
|
3216
|
+
</div>
|
|
2172
3217
|
<div>
|
|
2173
3218
|
<div class="logo-name">OpenQA</div>
|
|
2174
3219
|
<div class="logo-version">v2.1.0 \xB7 OSS</div>
|
|
@@ -2178,39 +3223,69 @@ function getDashboardHTML() {
|
|
|
2178
3223
|
<div class="nav-section">
|
|
2179
3224
|
<div class="nav-label">Overview</div>
|
|
2180
3225
|
<a class="nav-item active" href="/">
|
|
2181
|
-
<span class="icon"
|
|
3226
|
+
<span class="icon">
|
|
3227
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gauge-icon lucide-gauge"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></svg>
|
|
3228
|
+
</span> Dashboard
|
|
2182
3229
|
</a>
|
|
2183
3230
|
<a class="nav-item" href="/kanban">
|
|
2184
|
-
<span class="icon"
|
|
3231
|
+
<span class="icon">
|
|
3232
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-dashed-kanban-icon lucide-square-dashed-kanban"><path d="M8 7v7"/><path d="M12 7v4"/><path d="M16 7v9"/><path d="M5 3a2 2 0 0 0-2 2"/><path d="M9 3h1"/><path d="M14 3h1"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 9v1"/><path d="M21 14v1"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M14 21h1"/><path d="M9 21h1"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M3 14v1"/><path d="M3 9v1"/></svg>
|
|
3233
|
+
</span> Kanban
|
|
2185
3234
|
<span class="badge" id="kanban-count">0</span>
|
|
2186
3235
|
</a>
|
|
2187
3236
|
|
|
2188
3237
|
<div class="nav-label">Agents</div>
|
|
2189
3238
|
<a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('agents-table')">
|
|
2190
|
-
<span class="icon"
|
|
3239
|
+
<span class="icon">
|
|
3240
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-activity-icon lucide-activity"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>
|
|
3241
|
+
</span> Active Agents
|
|
2191
3242
|
</a>
|
|
2192
3243
|
<a class="nav-item" href="javascript:void(0)" onclick="switchAgentTab('specialists'); scrollToSection('agents-table')">
|
|
2193
|
-
<span class="icon"
|
|
3244
|
+
<span class="icon">
|
|
3245
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hat-glasses-icon lucide-hat-glasses"><path d="M14 18a2 2 0 0 0-4 0"/><path d="m19 11-2.11-6.657a2 2 0 0 0-2.752-1.148l-1.276.61A2 2 0 0 1 12 4H8.5a2 2 0 0 0-1.925 1.456L5 11"/><path d="M2 11h20"/><circle cx="17" cy="18" r="3"/><circle cx="7" cy="18" r="3"/></svg>
|
|
3246
|
+
</span> Specialists
|
|
2194
3247
|
</a>
|
|
2195
3248
|
<a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('interventions-panel')">
|
|
2196
|
-
<span class="icon"
|
|
3249
|
+
<span class="icon">
|
|
3250
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-cog-icon lucide-user-cog"><path d="M10 15H6a4 4 0 0 0-4 4v2"/><path d="m14.305 16.53.923-.382"/><path d="m15.228 13.852-.923-.383"/><path d="m16.852 12.228-.383-.923"/><path d="m16.852 17.772-.383.924"/><path d="m19.148 12.228.383-.923"/><path d="m19.53 18.696-.382-.924"/><path d="m20.772 13.852.924-.383"/><path d="m20.772 16.148.924.383"/><circle cx="18" cy="15" r="3"/><circle cx="9" cy="7" r="4"/></svg>
|
|
3251
|
+
</span> Interventions
|
|
2197
3252
|
<span class="badge" id="intervention-count" style="background: var(--red);">0</span>
|
|
2198
3253
|
</a>
|
|
2199
3254
|
|
|
2200
3255
|
<div class="nav-label">Analysis</div>
|
|
2201
3256
|
<a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('issues-panel')">
|
|
2202
|
-
<span class="icon"
|
|
3257
|
+
<span class="icon">
|
|
3258
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bug-play-icon lucide-bug-play"><path d="M10 19.655A6 6 0 0 1 6 14v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 3.97"/><path d="M14 15.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997a1 1 0 0 1-1.517-.86z"/>
|
|
3259
|
+
<path d="M14.12 3.88 16 2"/>
|
|
3260
|
+
<path d="M21 5a4 4 0 0 1-3.55 3.97"/>
|
|
3261
|
+
<path d="M3 21a4 4 0 0 1 3.81-4"/>
|
|
3262
|
+
<path d="M3 5a4 4 0 0 0 3.55 3.97"/>
|
|
3263
|
+
<path d="M6 13H2"/><path d="m8 2 1.88 1.88"/>
|
|
3264
|
+
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/>
|
|
3265
|
+
</svg>
|
|
3266
|
+
</span> Bug Reports
|
|
2203
3267
|
</a>
|
|
2204
3268
|
<a class="nav-item" href="javascript:void(0)" onclick="switchChartTab('performance'); scrollToSection('chart-performance')">
|
|
2205
|
-
<span class="icon"
|
|
3269
|
+
<span class="icon">
|
|
3270
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chart-spline-icon lucide-chart-spline"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M7 16c.5-2 1.5-7 4-7 2 0 2 3 4 3 2.5 0 4.5-5 5-7"/></svg>
|
|
3271
|
+
</span> Performance
|
|
2206
3272
|
</a>
|
|
2207
3273
|
<a class="nav-item" href="javascript:void(0)" onclick="scrollToSection('activity-list')">
|
|
2208
|
-
<span class="icon"
|
|
3274
|
+
<span class="icon">
|
|
3275
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scroll-text-icon lucide-scroll-text"><path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></svg>
|
|
3276
|
+
</span> Logs
|
|
2209
3277
|
</a>
|
|
2210
3278
|
|
|
2211
3279
|
<div class="nav-label">System</div>
|
|
2212
3280
|
<a class="nav-item" href="/config">
|
|
2213
|
-
<span class="icon"
|
|
3281
|
+
<span class="icon">
|
|
3282
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-columns3-cog-icon lucide-columns-3-cog"><path d="M10.5 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5.5"/><path d="m14.3 19.6 1-.4"/><path d="M15 3v7.5"/><path d="m15.2 16.9-.9-.3"/><path d="m16.6 21.7.3-.9"/><path d="m16.8 15.3-.4-1"/><path d="m19.1 15.2.3-.9"/><path d="m19.6 21.7-.4-1"/><path d="m20.7 16.8 1-.4"/><path d="m21.7 19.4-.9-.3"/><path d="M9 3v18"/><circle cx="18" cy="18" r="3"/></svg>
|
|
3283
|
+
</span> Config
|
|
3284
|
+
</a>
|
|
3285
|
+
<a class="nav-item" href="/config/env">
|
|
3286
|
+
<span class="icon">
|
|
3287
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-columns3-cog-icon lucide-columns-3-cog"><path d="M10.5 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5.5"/><path d="m14.3 19.6 1-.4"/><path d="M15 3v7.5"/><path d="m15.2 16.9-.9-.3"/><path d="m16.6 21.7.3-.9"/><path d="m16.8 15.3-.4-1"/><path d="m19.1 15.2.3-.9"/><path d="m19.6 21.7-.4-1"/><path d="m20.7 16.8 1-.4"/><path d="m21.7 19.4-.9-.3"/><path d="M9 3v18"/><circle cx="18" cy="18" r="3"/></svg>
|
|
3288
|
+
</span> Environment
|
|
2214
3289
|
</a>
|
|
2215
3290
|
</div>
|
|
2216
3291
|
|
|
@@ -2242,7 +3317,9 @@ function getDashboardHTML() {
|
|
|
2242
3317
|
<div class="metric-card">
|
|
2243
3318
|
<div class="metric-header">
|
|
2244
3319
|
<div class="metric-label">Active Agents</div>
|
|
2245
|
-
<div class="metric-icon"
|
|
3320
|
+
<div class="metric-icon">
|
|
3321
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-terminal-icon lucide-square-terminal"><path d="m7 11 2-2-2-2"/><path d="M11 13h4"/><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/></svg>
|
|
3322
|
+
</div>
|
|
2246
3323
|
</div>
|
|
2247
3324
|
<div class="metric-value" id="active-agents">0</div>
|
|
2248
3325
|
<div class="metric-change positive" id="agents-change">\u2191 0 from last hour</div>
|
|
@@ -2250,7 +3327,9 @@ function getDashboardHTML() {
|
|
|
2250
3327
|
<div class="metric-card">
|
|
2251
3328
|
<div class="metric-header">
|
|
2252
3329
|
<div class="metric-label">Total Actions</div>
|
|
2253
|
-
<div class="metric-icon"
|
|
3330
|
+
<div class="metric-icon">
|
|
3331
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-todo-icon lucide-list-todo"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><rect x="3" y="4" width="6" height="6" rx="1"/></svg>
|
|
3332
|
+
</div>
|
|
2254
3333
|
</div>
|
|
2255
3334
|
<div class="metric-value" id="total-actions">0</div>
|
|
2256
3335
|
<div class="metric-change positive" id="actions-change">\u2191 0% this session</div>
|
|
@@ -2258,7 +3337,9 @@ function getDashboardHTML() {
|
|
|
2258
3337
|
<div class="metric-card">
|
|
2259
3338
|
<div class="metric-header">
|
|
2260
3339
|
<div class="metric-label">Bugs Found</div>
|
|
2261
|
-
<div class="metric-icon"
|
|
3340
|
+
<div class="metric-icon">
|
|
3341
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-todo-icon lucide-list-todo"><path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><rect x="3" y="4" width="6" height="6" rx="1"/></svg>
|
|
3342
|
+
</div>
|
|
2262
3343
|
</div>
|
|
2263
3344
|
<div class="metric-value" id="bugs-found">0</div>
|
|
2264
3345
|
<div class="metric-change negative" id="bugs-change">\u2193 0 from yesterday</div>
|
|
@@ -2266,7 +3347,9 @@ function getDashboardHTML() {
|
|
|
2266
3347
|
<div class="metric-card">
|
|
2267
3348
|
<div class="metric-header">
|
|
2268
3349
|
<div class="metric-label">Success Rate</div>
|
|
2269
|
-
<div class="metric-icon"
|
|
3350
|
+
<div class="metric-icon">
|
|
3351
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-check-icon lucide-cloud-check"><path d="m17 15-5.5 5.5L9 18"/><path d="M5.516 16.07A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 3.501 7.327"/></svg>
|
|
3352
|
+
</div>
|
|
2270
3353
|
</div>
|
|
2271
3354
|
<div class="metric-value" id="success-rate">\u2014</div>
|
|
2272
3355
|
<div class="metric-change positive" id="rate-change">\u2191 0 pts improvement</div>
|
|
@@ -4115,15 +5198,1111 @@ function getConfigHTML(cfg) {
|
|
|
4115
5198
|
</html>`;
|
|
4116
5199
|
}
|
|
4117
5200
|
|
|
5201
|
+
// cli/login.html.ts
|
|
5202
|
+
init_esm_shims();
|
|
5203
|
+
function getLoginHTML(opts) {
|
|
5204
|
+
const error = opts?.error ?? "";
|
|
5205
|
+
return `<!DOCTYPE html>
|
|
5206
|
+
<html lang="en">
|
|
5207
|
+
<head>
|
|
5208
|
+
<meta charset="UTF-8">
|
|
5209
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
5210
|
+
<title>OpenQA \u2014 Sign In</title>
|
|
5211
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
5212
|
+
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
5213
|
+
<style>
|
|
5214
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
5215
|
+
:root {
|
|
5216
|
+
--bg: #080b10;
|
|
5217
|
+
--surface: #0d1117;
|
|
5218
|
+
--panel: #111720;
|
|
5219
|
+
--border: rgba(255,255,255,0.06);
|
|
5220
|
+
--border-hi: rgba(255,255,255,0.14);
|
|
5221
|
+
--accent: #f97316;
|
|
5222
|
+
--accent-lo: rgba(249,115,22,0.08);
|
|
5223
|
+
--text-1: #f1f5f9;
|
|
5224
|
+
--text-2: #8b98a8;
|
|
5225
|
+
--text-3: #4b5563;
|
|
5226
|
+
--red: #ef4444;
|
|
5227
|
+
--red-lo: rgba(239,68,68,0.10);
|
|
5228
|
+
}
|
|
5229
|
+
html, body { height: 100%; }
|
|
5230
|
+
body {
|
|
5231
|
+
background: var(--bg);
|
|
5232
|
+
font-family: 'Syne', sans-serif;
|
|
5233
|
+
color: var(--text-1);
|
|
5234
|
+
display: flex;
|
|
5235
|
+
align-items: center;
|
|
5236
|
+
justify-content: center;
|
|
5237
|
+
min-height: 100vh;
|
|
5238
|
+
}
|
|
5239
|
+
.card {
|
|
5240
|
+
width: 100%;
|
|
5241
|
+
max-width: 400px;
|
|
5242
|
+
background: var(--surface);
|
|
5243
|
+
border: 1px solid var(--border-hi);
|
|
5244
|
+
border-radius: 16px;
|
|
5245
|
+
padding: 40px 36px 36px;
|
|
5246
|
+
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
|
|
5247
|
+
}
|
|
5248
|
+
.logo {
|
|
5249
|
+
display: flex;
|
|
5250
|
+
align-items: center;
|
|
5251
|
+
gap: 10px;
|
|
5252
|
+
margin-bottom: 32px;
|
|
5253
|
+
justify-content: center;
|
|
5254
|
+
}
|
|
5255
|
+
.logo-mark {
|
|
5256
|
+
width: 36px; height: 36px;
|
|
5257
|
+
background: var(--accent);
|
|
5258
|
+
border-radius: 8px;
|
|
5259
|
+
display: flex; align-items: center; justify-content: center;
|
|
5260
|
+
font-size: 18px; font-weight: 800; color: #fff;
|
|
5261
|
+
}
|
|
5262
|
+
.logo-text { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
|
|
5263
|
+
h1 { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 6px; }
|
|
5264
|
+
.subtitle { font-size: 13px; color: var(--text-2); text-align: center; margin-bottom: 28px; }
|
|
5265
|
+
.field { margin-bottom: 16px; }
|
|
5266
|
+
label { display: block; font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 6px; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
5267
|
+
input[type=text], input[type=password] {
|
|
5268
|
+
width: 100%;
|
|
5269
|
+
background: var(--panel);
|
|
5270
|
+
border: 1px solid var(--border-hi);
|
|
5271
|
+
border-radius: 8px;
|
|
5272
|
+
padding: 10px 14px;
|
|
5273
|
+
font-family: 'DM Mono', monospace;
|
|
5274
|
+
font-size: 14px;
|
|
5275
|
+
color: var(--text-1);
|
|
5276
|
+
outline: none;
|
|
5277
|
+
transition: border-color 0.15s;
|
|
5278
|
+
}
|
|
5279
|
+
input:focus { border-color: var(--accent); }
|
|
5280
|
+
.error-msg {
|
|
5281
|
+
background: var(--red-lo);
|
|
5282
|
+
border: 1px solid rgba(239,68,68,0.3);
|
|
5283
|
+
border-radius: 8px;
|
|
5284
|
+
padding: 10px 14px;
|
|
5285
|
+
font-size: 13px;
|
|
5286
|
+
color: var(--red);
|
|
5287
|
+
margin-bottom: 18px;
|
|
5288
|
+
display: ${error ? "block" : "none"};
|
|
5289
|
+
}
|
|
5290
|
+
.btn {
|
|
5291
|
+
width: 100%;
|
|
5292
|
+
background: var(--accent);
|
|
5293
|
+
color: #fff;
|
|
5294
|
+
border: none;
|
|
5295
|
+
border-radius: 8px;
|
|
5296
|
+
padding: 12px;
|
|
5297
|
+
font-family: 'Syne', sans-serif;
|
|
5298
|
+
font-size: 15px;
|
|
5299
|
+
font-weight: 700;
|
|
5300
|
+
cursor: pointer;
|
|
5301
|
+
margin-top: 8px;
|
|
5302
|
+
transition: opacity 0.15s, transform 0.1s;
|
|
5303
|
+
}
|
|
5304
|
+
.btn:hover { opacity: 0.9; }
|
|
5305
|
+
.btn:active { transform: scale(0.98); }
|
|
5306
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
5307
|
+
.footer { margin-top: 28px; text-align: center; font-size: 12px; color: var(--text-3); font-family: 'DM Mono', monospace; }
|
|
5308
|
+
</style>
|
|
5309
|
+
</head>
|
|
5310
|
+
<body>
|
|
5311
|
+
<div class="card">
|
|
5312
|
+
<div class="logo">
|
|
5313
|
+
<div class="logo-mark">Q</div>
|
|
5314
|
+
<span class="logo-text">OpenQA</span>
|
|
5315
|
+
</div>
|
|
5316
|
+
<h1>Sign in</h1>
|
|
5317
|
+
<p class="subtitle">Access your QA dashboard</p>
|
|
5318
|
+
|
|
5319
|
+
<div class="error-msg" id="error">${error || "Invalid credentials"}</div>
|
|
5320
|
+
|
|
5321
|
+
<form id="loginForm">
|
|
5322
|
+
<div class="field">
|
|
5323
|
+
<label for="username">Username</label>
|
|
5324
|
+
<input type="text" id="username" name="username" autocomplete="username" autofocus required>
|
|
5325
|
+
</div>
|
|
5326
|
+
<div class="field">
|
|
5327
|
+
<label for="password">Password</label>
|
|
5328
|
+
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
|
5329
|
+
</div>
|
|
5330
|
+
<button type="submit" class="btn" id="submitBtn">Sign In</button>
|
|
5331
|
+
</form>
|
|
5332
|
+
|
|
5333
|
+
<div class="footer">OpenQA v1.3.4</div>
|
|
5334
|
+
</div>
|
|
5335
|
+
|
|
5336
|
+
<script>
|
|
5337
|
+
const form = document.getElementById('loginForm');
|
|
5338
|
+
const errorEl = document.getElementById('error');
|
|
5339
|
+
const btn = document.getElementById('submitBtn');
|
|
5340
|
+
|
|
5341
|
+
form.addEventListener('submit', async (e) => {
|
|
5342
|
+
e.preventDefault();
|
|
5343
|
+
errorEl.style.display = 'none';
|
|
5344
|
+
btn.disabled = true;
|
|
5345
|
+
btn.textContent = 'Signing in\u2026';
|
|
5346
|
+
try {
|
|
5347
|
+
const res = await fetch('/api/auth/login', {
|
|
5348
|
+
method: 'POST',
|
|
5349
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5350
|
+
body: JSON.stringify({
|
|
5351
|
+
username: document.getElementById('username').value.trim(),
|
|
5352
|
+
password: document.getElementById('password').value,
|
|
5353
|
+
}),
|
|
5354
|
+
credentials: 'include',
|
|
5355
|
+
});
|
|
5356
|
+
if (res.ok) {
|
|
5357
|
+
const params = new URLSearchParams(window.location.search);
|
|
5358
|
+
window.location.href = params.get('next') || '/';
|
|
5359
|
+
} else {
|
|
5360
|
+
const data = await res.json().catch(() => ({}));
|
|
5361
|
+
errorEl.textContent = data.error || 'Invalid credentials';
|
|
5362
|
+
errorEl.style.display = 'block';
|
|
5363
|
+
btn.disabled = false;
|
|
5364
|
+
btn.textContent = 'Sign In';
|
|
5365
|
+
}
|
|
5366
|
+
} catch {
|
|
5367
|
+
errorEl.textContent = 'Network error \u2014 please try again';
|
|
5368
|
+
errorEl.style.display = 'block';
|
|
5369
|
+
btn.disabled = false;
|
|
5370
|
+
btn.textContent = 'Sign In';
|
|
5371
|
+
}
|
|
5372
|
+
});
|
|
5373
|
+
</script>
|
|
5374
|
+
</body>
|
|
5375
|
+
</html>`;
|
|
5376
|
+
}
|
|
5377
|
+
|
|
5378
|
+
// cli/setup.html.ts
|
|
5379
|
+
init_esm_shims();
|
|
5380
|
+
function getSetupHTML() {
|
|
5381
|
+
return `<!DOCTYPE html>
|
|
5382
|
+
<html lang="en">
|
|
5383
|
+
<head>
|
|
5384
|
+
<meta charset="UTF-8">
|
|
5385
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
5386
|
+
<title>OpenQA \u2014 Setup</title>
|
|
5387
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
5388
|
+
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
5389
|
+
<style>
|
|
5390
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
5391
|
+
:root {
|
|
5392
|
+
--bg: #080b10;
|
|
5393
|
+
--surface: #0d1117;
|
|
5394
|
+
--panel: #111720;
|
|
5395
|
+
--border: rgba(255,255,255,0.06);
|
|
5396
|
+
--border-hi: rgba(255,255,255,0.14);
|
|
5397
|
+
--accent: #f97316;
|
|
5398
|
+
--accent-lo: rgba(249,115,22,0.08);
|
|
5399
|
+
--text-1: #f1f5f9;
|
|
5400
|
+
--text-2: #8b98a8;
|
|
5401
|
+
--text-3: #4b5563;
|
|
5402
|
+
--red: #ef4444;
|
|
5403
|
+
--red-lo: rgba(239,68,68,0.10);
|
|
5404
|
+
--green: #22c55e;
|
|
5405
|
+
--green-lo: rgba(34,197,94,0.08);
|
|
5406
|
+
}
|
|
5407
|
+
html, body { height: 100%; }
|
|
5408
|
+
body {
|
|
5409
|
+
background: var(--bg);
|
|
5410
|
+
font-family: 'Syne', sans-serif;
|
|
5411
|
+
color: var(--text-1);
|
|
5412
|
+
display: flex;
|
|
5413
|
+
align-items: center;
|
|
5414
|
+
justify-content: center;
|
|
5415
|
+
min-height: 100vh;
|
|
5416
|
+
}
|
|
5417
|
+
.card {
|
|
5418
|
+
width: 100%;
|
|
5419
|
+
max-width: 440px;
|
|
5420
|
+
background: var(--surface);
|
|
5421
|
+
border: 1px solid var(--border-hi);
|
|
5422
|
+
border-radius: 16px;
|
|
5423
|
+
padding: 40px 36px 36px;
|
|
5424
|
+
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
|
|
5425
|
+
}
|
|
5426
|
+
.logo {
|
|
5427
|
+
display: flex; align-items: center; gap: 10px;
|
|
5428
|
+
margin-bottom: 28px; justify-content: center;
|
|
5429
|
+
}
|
|
5430
|
+
.logo-mark {
|
|
5431
|
+
width: 36px; height: 36px;
|
|
5432
|
+
background: var(--accent);
|
|
5433
|
+
border-radius: 8px;
|
|
5434
|
+
display: flex; align-items: center; justify-content: center;
|
|
5435
|
+
font-size: 18px; font-weight: 800; color: #fff;
|
|
5436
|
+
}
|
|
5437
|
+
.logo-text { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
|
|
5438
|
+
h1 { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 6px; }
|
|
5439
|
+
.subtitle { font-size: 13px; color: var(--text-2); text-align: center; margin-bottom: 28px; }
|
|
5440
|
+
.badge {
|
|
5441
|
+
display: inline-block;
|
|
5442
|
+
background: var(--accent-lo);
|
|
5443
|
+
color: var(--accent);
|
|
5444
|
+
border: 1px solid rgba(249,115,22,0.25);
|
|
5445
|
+
border-radius: 6px;
|
|
5446
|
+
font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
|
|
5447
|
+
padding: 3px 8px; margin-bottom: 20px;
|
|
5448
|
+
text-align: center; width: 100%;
|
|
5449
|
+
}
|
|
5450
|
+
.field { margin-bottom: 16px; }
|
|
5451
|
+
label { display: block; font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 6px; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
5452
|
+
input[type=text], input[type=password] {
|
|
5453
|
+
width: 100%;
|
|
5454
|
+
background: var(--panel);
|
|
5455
|
+
border: 1px solid var(--border-hi);
|
|
5456
|
+
border-radius: 8px;
|
|
5457
|
+
padding: 10px 14px;
|
|
5458
|
+
font-family: 'DM Mono', monospace;
|
|
5459
|
+
font-size: 14px;
|
|
5460
|
+
color: var(--text-1);
|
|
5461
|
+
outline: none;
|
|
5462
|
+
transition: border-color 0.15s;
|
|
5463
|
+
}
|
|
5464
|
+
input:focus { border-color: var(--accent); }
|
|
5465
|
+
.hint { font-size: 11px; color: var(--text-3); margin-top: 5px; font-family: 'DM Mono', monospace; }
|
|
5466
|
+
.msg {
|
|
5467
|
+
border-radius: 8px;
|
|
5468
|
+
padding: 10px 14px;
|
|
5469
|
+
font-size: 13px;
|
|
5470
|
+
margin-bottom: 18px;
|
|
5471
|
+
display: none;
|
|
5472
|
+
}
|
|
5473
|
+
.msg.error { background: var(--red-lo); border: 1px solid rgba(239,68,68,0.3); color: var(--red); }
|
|
5474
|
+
.msg.success { background: var(--green-lo); border: 1px solid rgba(34,197,94,0.3); color: var(--green); }
|
|
5475
|
+
.btn {
|
|
5476
|
+
width: 100%;
|
|
5477
|
+
background: var(--accent);
|
|
5478
|
+
color: #fff;
|
|
5479
|
+
border: none;
|
|
5480
|
+
border-radius: 8px;
|
|
5481
|
+
padding: 12px;
|
|
5482
|
+
font-family: 'Syne', sans-serif;
|
|
5483
|
+
font-size: 15px; font-weight: 700;
|
|
5484
|
+
cursor: pointer; margin-top: 8px;
|
|
5485
|
+
transition: opacity 0.15s, transform 0.1s;
|
|
5486
|
+
}
|
|
5487
|
+
.btn:hover { opacity: 0.9; }
|
|
5488
|
+
.btn:active { transform: scale(0.98); }
|
|
5489
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
5490
|
+
.footer { margin-top: 28px; text-align: center; font-size: 12px; color: var(--text-3); font-family: 'DM Mono', monospace; }
|
|
5491
|
+
</style>
|
|
5492
|
+
</head>
|
|
5493
|
+
<body>
|
|
5494
|
+
<div class="card">
|
|
5495
|
+
<div class="logo">
|
|
5496
|
+
<div class="logo-mark">Q</div>
|
|
5497
|
+
<span class="logo-text">OpenQA</span>
|
|
5498
|
+
</div>
|
|
5499
|
+
<div class="badge">FIRST RUN</div>
|
|
5500
|
+
<h1>Create admin account</h1>
|
|
5501
|
+
<p class="subtitle">Set up your OpenQA instance</p>
|
|
5502
|
+
|
|
5503
|
+
<div class="msg error" id="error"></div>
|
|
5504
|
+
<div class="msg success" id="success"></div>
|
|
5505
|
+
|
|
5506
|
+
<form id="setupForm">
|
|
5507
|
+
<div class="field">
|
|
5508
|
+
<label for="username">Username or Email</label>
|
|
5509
|
+
<input type="text" id="username" name="username" autocomplete="username" autofocus required pattern="[a-z0-9_.@-]+" title="Lowercase letters, digits, and ._@- characters">
|
|
5510
|
+
<div class="hint">Use your email or a username (lowercase, digits, ._@- allowed)</div>
|
|
5511
|
+
</div>
|
|
5512
|
+
<div class="field">
|
|
5513
|
+
<label for="password">Password</label>
|
|
5514
|
+
<input type="password" id="password" name="password" autocomplete="new-password" required minlength="8">
|
|
5515
|
+
<div class="hint">Minimum 8 characters</div>
|
|
5516
|
+
</div>
|
|
5517
|
+
<div class="field">
|
|
5518
|
+
<label for="confirm">Confirm Password</label>
|
|
5519
|
+
<input type="password" id="confirm" name="confirm" autocomplete="new-password" required minlength="8">
|
|
5520
|
+
</div>
|
|
5521
|
+
<button type="submit" class="btn" id="submitBtn">Create Account & Sign In</button>
|
|
5522
|
+
</form>
|
|
5523
|
+
|
|
5524
|
+
<div class="footer">OpenQA v1.3.4</div>
|
|
5525
|
+
</div>
|
|
5526
|
+
|
|
5527
|
+
<script>
|
|
5528
|
+
const form = document.getElementById('setupForm');
|
|
5529
|
+
const errorEl = document.getElementById('error');
|
|
5530
|
+
const successEl = document.getElementById('success');
|
|
5531
|
+
const btn = document.getElementById('submitBtn');
|
|
5532
|
+
|
|
5533
|
+
form.addEventListener('submit', async (e) => {
|
|
5534
|
+
e.preventDefault();
|
|
5535
|
+
errorEl.style.display = 'none';
|
|
5536
|
+
successEl.style.display = 'none';
|
|
5537
|
+
|
|
5538
|
+
const password = document.getElementById('password').value;
|
|
5539
|
+
const confirm = document.getElementById('confirm').value;
|
|
5540
|
+
if (password !== confirm) {
|
|
5541
|
+
errorEl.textContent = 'Passwords do not match';
|
|
5542
|
+
errorEl.style.display = 'block';
|
|
5543
|
+
return;
|
|
5544
|
+
}
|
|
5545
|
+
|
|
5546
|
+
btn.disabled = true;
|
|
5547
|
+
btn.textContent = 'Creating account\u2026';
|
|
5548
|
+
|
|
5549
|
+
try {
|
|
5550
|
+
const res = await fetch('/api/setup', {
|
|
5551
|
+
method: 'POST',
|
|
5552
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5553
|
+
body: JSON.stringify({
|
|
5554
|
+
username: document.getElementById('username').value.trim(),
|
|
5555
|
+
password,
|
|
5556
|
+
}),
|
|
5557
|
+
credentials: 'include',
|
|
5558
|
+
});
|
|
5559
|
+
|
|
5560
|
+
if (res.ok) {
|
|
5561
|
+
successEl.textContent = 'Account created! Redirecting\u2026';
|
|
5562
|
+
successEl.style.display = 'block';
|
|
5563
|
+
setTimeout(() => { window.location.href = '/'; }, 800);
|
|
5564
|
+
} else {
|
|
5565
|
+
const data = await res.json().catch(() => ({}));
|
|
5566
|
+
errorEl.textContent = data.error || 'Setup failed';
|
|
5567
|
+
errorEl.style.display = 'block';
|
|
5568
|
+
btn.disabled = false;
|
|
5569
|
+
btn.textContent = 'Create Account & Sign In';
|
|
5570
|
+
}
|
|
5571
|
+
} catch {
|
|
5572
|
+
errorEl.textContent = 'Network error \u2014 please try again';
|
|
5573
|
+
errorEl.style.display = 'block';
|
|
5574
|
+
btn.disabled = false;
|
|
5575
|
+
btn.textContent = 'Create Account & Sign In';
|
|
5576
|
+
}
|
|
5577
|
+
});
|
|
5578
|
+
</script>
|
|
5579
|
+
</body>
|
|
5580
|
+
</html>`;
|
|
5581
|
+
}
|
|
5582
|
+
|
|
5583
|
+
// cli/env.html.ts
|
|
5584
|
+
init_esm_shims();
|
|
5585
|
+
function getEnvHTML() {
|
|
5586
|
+
return `<!DOCTYPE html>
|
|
5587
|
+
<html lang="en">
|
|
5588
|
+
<head>
|
|
5589
|
+
<meta charset="UTF-8">
|
|
5590
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
5591
|
+
<title>Environment Variables - OpenQA</title>
|
|
5592
|
+
<style>
|
|
5593
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
5594
|
+
|
|
5595
|
+
body {
|
|
5596
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
5597
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
5598
|
+
min-height: 100vh;
|
|
5599
|
+
padding: 20px;
|
|
5600
|
+
}
|
|
5601
|
+
|
|
5602
|
+
.container {
|
|
5603
|
+
max-width: 1200px;
|
|
5604
|
+
margin: 0 auto;
|
|
5605
|
+
}
|
|
5606
|
+
|
|
5607
|
+
.header {
|
|
5608
|
+
background: rgba(255, 255, 255, 0.95);
|
|
5609
|
+
backdrop-filter: blur(10px);
|
|
5610
|
+
padding: 20px 30px;
|
|
5611
|
+
border-radius: 12px;
|
|
5612
|
+
margin-bottom: 20px;
|
|
5613
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
5614
|
+
display: flex;
|
|
5615
|
+
justify-content: space-between;
|
|
5616
|
+
align-items: center;
|
|
5617
|
+
}
|
|
5618
|
+
|
|
5619
|
+
.header h1 {
|
|
5620
|
+
font-size: 24px;
|
|
5621
|
+
color: #1a202c;
|
|
5622
|
+
display: flex;
|
|
5623
|
+
align-items: center;
|
|
5624
|
+
gap: 10px;
|
|
5625
|
+
}
|
|
5626
|
+
|
|
5627
|
+
.header-actions {
|
|
5628
|
+
display: flex;
|
|
5629
|
+
gap: 10px;
|
|
5630
|
+
}
|
|
5631
|
+
|
|
5632
|
+
.btn {
|
|
5633
|
+
padding: 10px 20px;
|
|
5634
|
+
border: none;
|
|
5635
|
+
border-radius: 8px;
|
|
5636
|
+
font-size: 14px;
|
|
5637
|
+
font-weight: 600;
|
|
5638
|
+
cursor: pointer;
|
|
5639
|
+
transition: all 0.2s;
|
|
5640
|
+
text-decoration: none;
|
|
5641
|
+
display: inline-flex;
|
|
5642
|
+
align-items: center;
|
|
5643
|
+
gap: 8px;
|
|
5644
|
+
}
|
|
5645
|
+
|
|
5646
|
+
.btn-primary {
|
|
5647
|
+
background: #667eea;
|
|
5648
|
+
color: white;
|
|
5649
|
+
}
|
|
5650
|
+
|
|
5651
|
+
.btn-primary:hover {
|
|
5652
|
+
background: #5568d3;
|
|
5653
|
+
transform: translateY(-1px);
|
|
5654
|
+
}
|
|
5655
|
+
|
|
5656
|
+
.btn-secondary {
|
|
5657
|
+
background: #e2e8f0;
|
|
5658
|
+
color: #4a5568;
|
|
5659
|
+
}
|
|
5660
|
+
|
|
5661
|
+
.btn-secondary:hover {
|
|
5662
|
+
background: #cbd5e0;
|
|
5663
|
+
}
|
|
5664
|
+
|
|
5665
|
+
.btn-success {
|
|
5666
|
+
background: #48bb78;
|
|
5667
|
+
color: white;
|
|
5668
|
+
}
|
|
5669
|
+
|
|
5670
|
+
.btn-success:hover {
|
|
5671
|
+
background: #38a169;
|
|
5672
|
+
}
|
|
5673
|
+
|
|
5674
|
+
.btn:disabled {
|
|
5675
|
+
opacity: 0.5;
|
|
5676
|
+
cursor: not-allowed;
|
|
5677
|
+
}
|
|
5678
|
+
|
|
5679
|
+
.content {
|
|
5680
|
+
background: rgba(255, 255, 255, 0.95);
|
|
5681
|
+
backdrop-filter: blur(10px);
|
|
5682
|
+
padding: 30px;
|
|
5683
|
+
border-radius: 12px;
|
|
5684
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
5685
|
+
}
|
|
5686
|
+
|
|
5687
|
+
.tabs {
|
|
5688
|
+
display: flex;
|
|
5689
|
+
gap: 10px;
|
|
5690
|
+
margin-bottom: 30px;
|
|
5691
|
+
border-bottom: 2px solid #e2e8f0;
|
|
5692
|
+
padding-bottom: 10px;
|
|
5693
|
+
}
|
|
5694
|
+
|
|
5695
|
+
.tab {
|
|
5696
|
+
padding: 10px 20px;
|
|
5697
|
+
border: none;
|
|
5698
|
+
background: none;
|
|
5699
|
+
font-size: 14px;
|
|
5700
|
+
font-weight: 600;
|
|
5701
|
+
color: #718096;
|
|
5702
|
+
cursor: pointer;
|
|
5703
|
+
border-bottom: 3px solid transparent;
|
|
5704
|
+
transition: all 0.2s;
|
|
5705
|
+
}
|
|
5706
|
+
|
|
5707
|
+
.tab.active {
|
|
5708
|
+
color: #667eea;
|
|
5709
|
+
border-bottom-color: #667eea;
|
|
5710
|
+
}
|
|
5711
|
+
|
|
5712
|
+
.tab:hover {
|
|
5713
|
+
color: #667eea;
|
|
5714
|
+
}
|
|
5715
|
+
|
|
5716
|
+
.category-section {
|
|
5717
|
+
display: none;
|
|
5718
|
+
}
|
|
5719
|
+
|
|
5720
|
+
.category-section.active {
|
|
5721
|
+
display: block;
|
|
5722
|
+
}
|
|
5723
|
+
|
|
5724
|
+
.category-header {
|
|
5725
|
+
display: flex;
|
|
5726
|
+
justify-content: space-between;
|
|
5727
|
+
align-items: center;
|
|
5728
|
+
margin-bottom: 20px;
|
|
5729
|
+
}
|
|
5730
|
+
|
|
5731
|
+
.category-title {
|
|
5732
|
+
font-size: 18px;
|
|
5733
|
+
font-weight: 600;
|
|
5734
|
+
color: #2d3748;
|
|
5735
|
+
}
|
|
5736
|
+
|
|
5737
|
+
.env-grid {
|
|
5738
|
+
display: grid;
|
|
5739
|
+
gap: 20px;
|
|
5740
|
+
}
|
|
5741
|
+
|
|
5742
|
+
.env-item {
|
|
5743
|
+
border: 1px solid #e2e8f0;
|
|
5744
|
+
border-radius: 8px;
|
|
5745
|
+
padding: 20px;
|
|
5746
|
+
transition: all 0.2s;
|
|
5747
|
+
}
|
|
5748
|
+
|
|
5749
|
+
.env-item:hover {
|
|
5750
|
+
border-color: #cbd5e0;
|
|
5751
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
5752
|
+
}
|
|
5753
|
+
|
|
5754
|
+
.env-item-header {
|
|
5755
|
+
display: flex;
|
|
5756
|
+
justify-content: space-between;
|
|
5757
|
+
align-items: flex-start;
|
|
5758
|
+
margin-bottom: 10px;
|
|
5759
|
+
}
|
|
5760
|
+
|
|
5761
|
+
.env-label {
|
|
5762
|
+
font-weight: 600;
|
|
5763
|
+
color: #2d3748;
|
|
5764
|
+
font-size: 14px;
|
|
5765
|
+
display: flex;
|
|
5766
|
+
align-items: center;
|
|
5767
|
+
gap: 8px;
|
|
5768
|
+
}
|
|
5769
|
+
|
|
5770
|
+
.required-badge {
|
|
5771
|
+
background: #fc8181;
|
|
5772
|
+
color: white;
|
|
5773
|
+
font-size: 10px;
|
|
5774
|
+
padding: 2px 6px;
|
|
5775
|
+
border-radius: 4px;
|
|
5776
|
+
font-weight: 700;
|
|
5777
|
+
}
|
|
5778
|
+
|
|
5779
|
+
.env-description {
|
|
5780
|
+
font-size: 13px;
|
|
5781
|
+
color: #718096;
|
|
5782
|
+
margin-bottom: 10px;
|
|
5783
|
+
}
|
|
5784
|
+
|
|
5785
|
+
.env-input-group {
|
|
5786
|
+
display: flex;
|
|
5787
|
+
gap: 10px;
|
|
5788
|
+
align-items: center;
|
|
5789
|
+
}
|
|
5790
|
+
|
|
5791
|
+
.env-input {
|
|
5792
|
+
flex: 1;
|
|
5793
|
+
padding: 10px 12px;
|
|
5794
|
+
border: 1px solid #e2e8f0;
|
|
5795
|
+
border-radius: 6px;
|
|
5796
|
+
font-size: 14px;
|
|
5797
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
5798
|
+
transition: all 0.2s;
|
|
5799
|
+
}
|
|
5800
|
+
|
|
5801
|
+
.env-input:focus {
|
|
5802
|
+
outline: none;
|
|
5803
|
+
border-color: #667eea;
|
|
5804
|
+
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
5805
|
+
}
|
|
5806
|
+
|
|
5807
|
+
.env-input.error {
|
|
5808
|
+
border-color: #fc8181;
|
|
5809
|
+
}
|
|
5810
|
+
|
|
5811
|
+
.env-actions {
|
|
5812
|
+
display: flex;
|
|
5813
|
+
gap: 5px;
|
|
5814
|
+
}
|
|
5815
|
+
|
|
5816
|
+
.icon-btn {
|
|
5817
|
+
padding: 8px;
|
|
5818
|
+
border: none;
|
|
5819
|
+
background: #e2e8f0;
|
|
5820
|
+
border-radius: 6px;
|
|
5821
|
+
cursor: pointer;
|
|
5822
|
+
transition: all 0.2s;
|
|
5823
|
+
font-size: 16px;
|
|
5824
|
+
}
|
|
5825
|
+
|
|
5826
|
+
.icon-btn:hover {
|
|
5827
|
+
background: #cbd5e0;
|
|
5828
|
+
}
|
|
5829
|
+
|
|
5830
|
+
.icon-btn.test {
|
|
5831
|
+
background: #bee3f8;
|
|
5832
|
+
color: #2c5282;
|
|
5833
|
+
}
|
|
5834
|
+
|
|
5835
|
+
.icon-btn.test:hover {
|
|
5836
|
+
background: #90cdf4;
|
|
5837
|
+
}
|
|
5838
|
+
|
|
5839
|
+
.icon-btn.generate {
|
|
5840
|
+
background: #c6f6d5;
|
|
5841
|
+
color: #22543d;
|
|
5842
|
+
}
|
|
5843
|
+
|
|
5844
|
+
.icon-btn.generate:hover {
|
|
5845
|
+
background: #9ae6b4;
|
|
5846
|
+
}
|
|
5847
|
+
|
|
5848
|
+
.error-message {
|
|
5849
|
+
color: #e53e3e;
|
|
5850
|
+
font-size: 12px;
|
|
5851
|
+
margin-top: 5px;
|
|
5852
|
+
}
|
|
5853
|
+
|
|
5854
|
+
.success-message {
|
|
5855
|
+
color: #38a169;
|
|
5856
|
+
font-size: 12px;
|
|
5857
|
+
margin-top: 5px;
|
|
5858
|
+
}
|
|
5859
|
+
|
|
5860
|
+
.alert {
|
|
5861
|
+
padding: 15px 20px;
|
|
5862
|
+
border-radius: 8px;
|
|
5863
|
+
margin-bottom: 20px;
|
|
5864
|
+
display: flex;
|
|
5865
|
+
align-items: center;
|
|
5866
|
+
gap: 10px;
|
|
5867
|
+
}
|
|
5868
|
+
|
|
5869
|
+
.alert-warning {
|
|
5870
|
+
background: #fef5e7;
|
|
5871
|
+
border-left: 4px solid #f59e0b;
|
|
5872
|
+
color: #92400e;
|
|
5873
|
+
}
|
|
5874
|
+
|
|
5875
|
+
.alert-info {
|
|
5876
|
+
background: #eff6ff;
|
|
5877
|
+
border-left: 4px solid #3b82f6;
|
|
5878
|
+
color: #1e40af;
|
|
5879
|
+
}
|
|
5880
|
+
|
|
5881
|
+
.alert-success {
|
|
5882
|
+
background: #f0fdf4;
|
|
5883
|
+
border-left: 4px solid #10b981;
|
|
5884
|
+
color: #065f46;
|
|
5885
|
+
}
|
|
5886
|
+
|
|
5887
|
+
.loading {
|
|
5888
|
+
text-align: center;
|
|
5889
|
+
padding: 40px;
|
|
5890
|
+
color: #718096;
|
|
5891
|
+
}
|
|
5892
|
+
|
|
5893
|
+
.spinner {
|
|
5894
|
+
border: 3px solid #e2e8f0;
|
|
5895
|
+
border-top: 3px solid #667eea;
|
|
5896
|
+
border-radius: 50%;
|
|
5897
|
+
width: 40px;
|
|
5898
|
+
height: 40px;
|
|
5899
|
+
animation: spin 1s linear infinite;
|
|
5900
|
+
margin: 0 auto 20px;
|
|
5901
|
+
}
|
|
5902
|
+
|
|
5903
|
+
@keyframes spin {
|
|
5904
|
+
0% { transform: rotate(0deg); }
|
|
5905
|
+
100% { transform: rotate(360deg); }
|
|
5906
|
+
}
|
|
5907
|
+
|
|
5908
|
+
.modal {
|
|
5909
|
+
display: none;
|
|
5910
|
+
position: fixed;
|
|
5911
|
+
top: 0;
|
|
5912
|
+
left: 0;
|
|
5913
|
+
right: 0;
|
|
5914
|
+
bottom: 0;
|
|
5915
|
+
background: rgba(0, 0, 0, 0.5);
|
|
5916
|
+
z-index: 1000;
|
|
5917
|
+
align-items: center;
|
|
5918
|
+
justify-content: center;
|
|
5919
|
+
}
|
|
5920
|
+
|
|
5921
|
+
.modal.show {
|
|
5922
|
+
display: flex;
|
|
5923
|
+
}
|
|
5924
|
+
|
|
5925
|
+
.modal-content {
|
|
5926
|
+
background: white;
|
|
5927
|
+
padding: 30px;
|
|
5928
|
+
border-radius: 12px;
|
|
5929
|
+
max-width: 500px;
|
|
5930
|
+
width: 90%;
|
|
5931
|
+
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
|
5932
|
+
}
|
|
5933
|
+
|
|
5934
|
+
.modal-header {
|
|
5935
|
+
font-size: 20px;
|
|
5936
|
+
font-weight: 600;
|
|
5937
|
+
margin-bottom: 15px;
|
|
5938
|
+
color: #2d3748;
|
|
5939
|
+
}
|
|
5940
|
+
|
|
5941
|
+
.modal-body {
|
|
5942
|
+
margin-bottom: 20px;
|
|
5943
|
+
color: #4a5568;
|
|
5944
|
+
}
|
|
5945
|
+
|
|
5946
|
+
.modal-footer {
|
|
5947
|
+
display: flex;
|
|
5948
|
+
gap: 10px;
|
|
5949
|
+
justify-content: flex-end;
|
|
5950
|
+
}
|
|
5951
|
+
</style>
|
|
5952
|
+
</head>
|
|
5953
|
+
<body>
|
|
5954
|
+
<div class="container">
|
|
5955
|
+
<div class="header">
|
|
5956
|
+
<h1>
|
|
5957
|
+
<span>\u2699\uFE0F</span>
|
|
5958
|
+
Environment Variables
|
|
5959
|
+
</h1>
|
|
5960
|
+
<div class="header-actions">
|
|
5961
|
+
<a href="/config" class="btn btn-secondary">\u2190 Back to Config</a>
|
|
5962
|
+
<button id="saveBtn" class="btn btn-success" disabled>\u{1F4BE} Save Changes</button>
|
|
5963
|
+
</div>
|
|
5964
|
+
</div>
|
|
5965
|
+
|
|
5966
|
+
<div class="content">
|
|
5967
|
+
<div id="loading" class="loading">
|
|
5968
|
+
<div class="spinner"></div>
|
|
5969
|
+
<div>Loading environment variables...</div>
|
|
5970
|
+
</div>
|
|
5971
|
+
|
|
5972
|
+
<div id="main" style="display: none;">
|
|
5973
|
+
<div id="alerts"></div>
|
|
5974
|
+
|
|
5975
|
+
<div class="tabs">
|
|
5976
|
+
<button class="tab active" data-category="llm">\u{1F916} LLM</button>
|
|
5977
|
+
<button class="tab" data-category="security">\u{1F512} Security</button>
|
|
5978
|
+
<button class="tab" data-category="target">\u{1F3AF} Target App</button>
|
|
5979
|
+
<button class="tab" data-category="github">\u{1F419} GitHub</button>
|
|
5980
|
+
<button class="tab" data-category="web">\u{1F310} Web Server</button>
|
|
5981
|
+
<button class="tab" data-category="agent">\u{1F916} Agent</button>
|
|
5982
|
+
<button class="tab" data-category="database">\u{1F4BE} Database</button>
|
|
5983
|
+
<button class="tab" data-category="notifications">\u{1F514} Notifications</button>
|
|
5984
|
+
</div>
|
|
5985
|
+
|
|
5986
|
+
<div id="categories"></div>
|
|
5987
|
+
</div>
|
|
5988
|
+
</div>
|
|
5989
|
+
</div>
|
|
5990
|
+
|
|
5991
|
+
<!-- Test Result Modal -->
|
|
5992
|
+
<div id="testModal" class="modal">
|
|
5993
|
+
<div class="modal-content">
|
|
5994
|
+
<div class="modal-header">Test Result</div>
|
|
5995
|
+
<div class="modal-body" id="testResult"></div>
|
|
5996
|
+
<div class="modal-footer">
|
|
5997
|
+
<button class="btn btn-secondary" onclick="closeTestModal()">Close</button>
|
|
5998
|
+
</div>
|
|
5999
|
+
</div>
|
|
6000
|
+
</div>
|
|
6001
|
+
|
|
6002
|
+
<script>
|
|
6003
|
+
let envVariables = [];
|
|
6004
|
+
let changedVariables = {};
|
|
6005
|
+
let restartRequired = false;
|
|
6006
|
+
|
|
6007
|
+
// Load environment variables
|
|
6008
|
+
async function loadEnvVariables() {
|
|
6009
|
+
try {
|
|
6010
|
+
const response = await fetch('/api/env');
|
|
6011
|
+
if (!response.ok) throw new Error('Failed to load variables');
|
|
6012
|
+
|
|
6013
|
+
const data = await response.json();
|
|
6014
|
+
envVariables = data.variables;
|
|
6015
|
+
|
|
6016
|
+
renderCategories();
|
|
6017
|
+
document.getElementById('loading').style.display = 'none';
|
|
6018
|
+
document.getElementById('main').style.display = 'block';
|
|
6019
|
+
} catch (error) {
|
|
6020
|
+
showAlert('error', 'Failed to load environment variables: ' + error.message);
|
|
6021
|
+
}
|
|
6022
|
+
}
|
|
6023
|
+
|
|
6024
|
+
// Render categories
|
|
6025
|
+
function renderCategories() {
|
|
6026
|
+
const container = document.getElementById('categories');
|
|
6027
|
+
const categories = [...new Set(envVariables.map(v => v.category))];
|
|
6028
|
+
|
|
6029
|
+
categories.forEach((category, index) => {
|
|
6030
|
+
const section = document.createElement('div');
|
|
6031
|
+
section.className = 'category-section' + (index === 0 ? ' active' : '');
|
|
6032
|
+
section.dataset.category = category;
|
|
6033
|
+
|
|
6034
|
+
const vars = envVariables.filter(v => v.category === category);
|
|
6035
|
+
|
|
6036
|
+
section.innerHTML = \`
|
|
6037
|
+
<div class="category-header">
|
|
6038
|
+
<div class="category-title">\${getCategoryTitle(category)}</div>
|
|
6039
|
+
</div>
|
|
6040
|
+
<div class="env-grid">
|
|
6041
|
+
\${vars.map(v => renderEnvItem(v)).join('')}
|
|
6042
|
+
</div>
|
|
6043
|
+
\`;
|
|
6044
|
+
|
|
6045
|
+
container.appendChild(section);
|
|
6046
|
+
});
|
|
6047
|
+
}
|
|
6048
|
+
|
|
6049
|
+
// Render single env item
|
|
6050
|
+
function renderEnvItem(envVar) {
|
|
6051
|
+
const inputType = envVar.type === 'password' ? 'password' : 'text';
|
|
6052
|
+
const value = envVar.displayValue || '';
|
|
6053
|
+
|
|
6054
|
+
return \`
|
|
6055
|
+
<div class="env-item" data-key="\${envVar.key}">
|
|
6056
|
+
<div class="env-item-header">
|
|
6057
|
+
<div class="env-label">
|
|
6058
|
+
\${envVar.key}
|
|
6059
|
+
\${envVar.required ? '<span class="required-badge">REQUIRED</span>' : ''}
|
|
6060
|
+
</div>
|
|
6061
|
+
</div>
|
|
6062
|
+
<div class="env-description">\${envVar.description}</div>
|
|
6063
|
+
<div class="env-input-group">
|
|
6064
|
+
\${envVar.type === 'select' ?
|
|
6065
|
+
\`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
|
|
6066
|
+
<option value="">-- Select --</option>
|
|
6067
|
+
\${envVar.options.map(opt =>
|
|
6068
|
+
\`<option value="\${opt}" \${value === opt ? 'selected' : ''}>\${opt}</option>\`
|
|
6069
|
+
).join('')}
|
|
6070
|
+
</select>\` :
|
|
6071
|
+
envVar.type === 'boolean' ?
|
|
6072
|
+
\`<select class="env-input" data-key="\${envVar.key}" onchange="handleChange(this)">
|
|
6073
|
+
<option value="">-- Select --</option>
|
|
6074
|
+
<option value="true" \${value === 'true' ? 'selected' : ''}>true</option>
|
|
6075
|
+
<option value="false" \${value === 'false' ? 'selected' : ''}>false</option>
|
|
6076
|
+
</select>\` :
|
|
6077
|
+
\`<input
|
|
6078
|
+
type="\${inputType}"
|
|
6079
|
+
class="env-input"
|
|
6080
|
+
data-key="\${envVar.key}"
|
|
6081
|
+
value="\${value}"
|
|
6082
|
+
placeholder="\${envVar.placeholder || ''}"
|
|
6083
|
+
onchange="handleChange(this)"
|
|
6084
|
+
/>\`
|
|
6085
|
+
}
|
|
6086
|
+
<div class="env-actions">
|
|
6087
|
+
\${envVar.testable ? \`<button class="icon-btn test" onclick="testVariable('\${envVar.key}')" title="Test">\u{1F9EA}</button>\` : ''}
|
|
6088
|
+
\${envVar.key === 'OPENQA_JWT_SECRET' ? \`<button class="icon-btn generate" onclick="generateSecret('\${envVar.key}')" title="Generate">\u{1F511}</button>\` : ''}
|
|
6089
|
+
</div>
|
|
6090
|
+
</div>
|
|
6091
|
+
<div class="error-message" id="error-\${envVar.key}"></div>
|
|
6092
|
+
<div class="success-message" id="success-\${envVar.key}"></div>
|
|
6093
|
+
</div>
|
|
6094
|
+
\`;
|
|
6095
|
+
}
|
|
6096
|
+
|
|
6097
|
+
// Handle input change
|
|
6098
|
+
function handleChange(input) {
|
|
6099
|
+
const key = input.dataset.key;
|
|
6100
|
+
const value = input.value;
|
|
6101
|
+
|
|
6102
|
+
changedVariables[key] = value;
|
|
6103
|
+
document.getElementById('saveBtn').disabled = false;
|
|
6104
|
+
|
|
6105
|
+
// Clear messages
|
|
6106
|
+
document.getElementById(\`error-\${key}\`).textContent = '';
|
|
6107
|
+
document.getElementById(\`success-\${key}\`).textContent = '';
|
|
6108
|
+
}
|
|
6109
|
+
|
|
6110
|
+
// Save changes
|
|
6111
|
+
async function saveChanges() {
|
|
6112
|
+
const saveBtn = document.getElementById('saveBtn');
|
|
6113
|
+
saveBtn.disabled = true;
|
|
6114
|
+
saveBtn.textContent = '\u{1F4BE} Saving...';
|
|
6115
|
+
|
|
6116
|
+
try {
|
|
6117
|
+
const response = await fetch('/api/env/bulk', {
|
|
6118
|
+
method: 'POST',
|
|
6119
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6120
|
+
body: JSON.stringify({ variables: changedVariables }),
|
|
6121
|
+
});
|
|
6122
|
+
|
|
6123
|
+
if (!response.ok) {
|
|
6124
|
+
const error = await response.json();
|
|
6125
|
+
throw new Error(error.error || 'Failed to save');
|
|
6126
|
+
}
|
|
6127
|
+
|
|
6128
|
+
const result = await response.json();
|
|
6129
|
+
restartRequired = result.restartRequired;
|
|
6130
|
+
|
|
6131
|
+
showAlert('success', \`\u2705 Saved \${result.updated} variable(s) successfully!\` +
|
|
6132
|
+
(restartRequired ? ' \u26A0\uFE0F Restart required for changes to take effect.' : ''));
|
|
6133
|
+
|
|
6134
|
+
changedVariables = {};
|
|
6135
|
+
saveBtn.textContent = '\u{1F4BE} Save Changes';
|
|
6136
|
+
|
|
6137
|
+
// Reload to show updated values
|
|
6138
|
+
setTimeout(() => location.reload(), 2000);
|
|
6139
|
+
} catch (error) {
|
|
6140
|
+
showAlert('error', 'Failed to save: ' + error.message);
|
|
6141
|
+
saveBtn.disabled = false;
|
|
6142
|
+
saveBtn.textContent = '\u{1F4BE} Save Changes';
|
|
6143
|
+
}
|
|
6144
|
+
}
|
|
6145
|
+
|
|
6146
|
+
// Test variable
|
|
6147
|
+
async function testVariable(key) {
|
|
6148
|
+
const input = document.querySelector(\`[data-key="\${key}"]\`);
|
|
6149
|
+
const value = input.value;
|
|
6150
|
+
|
|
6151
|
+
if (!value) {
|
|
6152
|
+
showAlert('warning', 'Please enter a value first');
|
|
6153
|
+
return;
|
|
6154
|
+
}
|
|
6155
|
+
|
|
6156
|
+
try {
|
|
6157
|
+
const response = await fetch(\`/api/env/test/\${key}\`, {
|
|
6158
|
+
method: 'POST',
|
|
6159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6160
|
+
body: JSON.stringify({ value }),
|
|
6161
|
+
});
|
|
6162
|
+
|
|
6163
|
+
const result = await response.json();
|
|
6164
|
+
showTestResult(result);
|
|
6165
|
+
} catch (error) {
|
|
6166
|
+
showTestResult({ success: false, message: 'Test failed: ' + error.message });
|
|
6167
|
+
}
|
|
6168
|
+
}
|
|
6169
|
+
|
|
6170
|
+
// Generate secret
|
|
6171
|
+
async function generateSecret(key) {
|
|
6172
|
+
try {
|
|
6173
|
+
const response = await fetch(\`/api/env/generate/\${key}\`, {
|
|
6174
|
+
method: 'POST',
|
|
6175
|
+
});
|
|
6176
|
+
|
|
6177
|
+
if (!response.ok) throw new Error('Failed to generate');
|
|
6178
|
+
|
|
6179
|
+
const result = await response.json();
|
|
6180
|
+
const input = document.querySelector(\`[data-key="\${key}"]\`);
|
|
6181
|
+
input.value = result.value;
|
|
6182
|
+
handleChange(input);
|
|
6183
|
+
|
|
6184
|
+
document.getElementById(\`success-\${key}\`).textContent = '\u2705 Secret generated!';
|
|
6185
|
+
} catch (error) {
|
|
6186
|
+
document.getElementById(\`error-\${key}\`).textContent = 'Failed to generate: ' + error.message;
|
|
6187
|
+
}
|
|
6188
|
+
}
|
|
6189
|
+
|
|
6190
|
+
// Show test result
|
|
6191
|
+
function showTestResult(result) {
|
|
6192
|
+
const modal = document.getElementById('testModal');
|
|
6193
|
+
const resultDiv = document.getElementById('testResult');
|
|
6194
|
+
|
|
6195
|
+
resultDiv.innerHTML = \`
|
|
6196
|
+
<div class="alert \${result.success ? 'alert-success' : 'alert-warning'}">
|
|
6197
|
+
\${result.success ? '\u2705' : '\u274C'} \${result.message}
|
|
6198
|
+
</div>
|
|
6199
|
+
\`;
|
|
6200
|
+
|
|
6201
|
+
modal.classList.add('show');
|
|
6202
|
+
}
|
|
6203
|
+
|
|
6204
|
+
function closeTestModal() {
|
|
6205
|
+
document.getElementById('testModal').classList.remove('show');
|
|
6206
|
+
}
|
|
6207
|
+
|
|
6208
|
+
// Show alert
|
|
6209
|
+
function showAlert(type, message) {
|
|
6210
|
+
const alerts = document.getElementById('alerts');
|
|
6211
|
+
const alertClass = type === 'error' ? 'alert-warning' :
|
|
6212
|
+
type === 'success' ? 'alert-success' : 'alert-info';
|
|
6213
|
+
|
|
6214
|
+
alerts.innerHTML = \`
|
|
6215
|
+
<div class="alert \${alertClass}">
|
|
6216
|
+
\${message}
|
|
6217
|
+
</div>
|
|
6218
|
+
\`;
|
|
6219
|
+
|
|
6220
|
+
setTimeout(() => alerts.innerHTML = '', 5000);
|
|
6221
|
+
}
|
|
6222
|
+
|
|
6223
|
+
// Get category title
|
|
6224
|
+
function getCategoryTitle(category) {
|
|
6225
|
+
const titles = {
|
|
6226
|
+
llm: '\u{1F916} LLM Configuration',
|
|
6227
|
+
security: '\u{1F512} Security Settings',
|
|
6228
|
+
target: '\u{1F3AF} Target Application',
|
|
6229
|
+
github: '\u{1F419} GitHub Integration',
|
|
6230
|
+
web: '\u{1F310} Web Server',
|
|
6231
|
+
agent: '\u{1F916} Agent Configuration',
|
|
6232
|
+
database: '\u{1F4BE} Database',
|
|
6233
|
+
notifications: '\u{1F514} Notifications',
|
|
6234
|
+
};
|
|
6235
|
+
return titles[category] || category;
|
|
6236
|
+
}
|
|
6237
|
+
|
|
6238
|
+
// Tab switching
|
|
6239
|
+
document.addEventListener('click', (e) => {
|
|
6240
|
+
if (e.target.classList.contains('tab')) {
|
|
6241
|
+
const category = e.target.dataset.category;
|
|
6242
|
+
|
|
6243
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
6244
|
+
e.target.classList.add('active');
|
|
6245
|
+
|
|
6246
|
+
document.querySelectorAll('.category-section').forEach(s => s.classList.remove('active'));
|
|
6247
|
+
document.querySelector(\`[data-category="\${category}"]\`).classList.add('active');
|
|
6248
|
+
}
|
|
6249
|
+
});
|
|
6250
|
+
|
|
6251
|
+
// Save button
|
|
6252
|
+
document.getElementById('saveBtn').addEventListener('click', saveChanges);
|
|
6253
|
+
|
|
6254
|
+
// Load on page load
|
|
6255
|
+
loadEnvVariables();
|
|
6256
|
+
</script>
|
|
6257
|
+
</body>
|
|
6258
|
+
</html>`;
|
|
6259
|
+
}
|
|
6260
|
+
|
|
4118
6261
|
// cli/server.ts
|
|
4119
6262
|
import chalk from "chalk";
|
|
6263
|
+
import rateLimit from "express-rate-limit";
|
|
6264
|
+
import cors from "cors";
|
|
4120
6265
|
async function startWebServer() {
|
|
4121
6266
|
const config = new ConfigManager();
|
|
4122
6267
|
const cfg = config.getConfigSync();
|
|
4123
6268
|
const db = new OpenQADatabase("./data/openqa.json");
|
|
4124
6269
|
const app = express();
|
|
4125
|
-
|
|
6270
|
+
const allowedOrigins = (process.env.CORS_ORIGINS || `http://localhost:${cfg.web.port}`).split(",");
|
|
6271
|
+
app.use(cors({
|
|
6272
|
+
origin: (origin, cb) => {
|
|
6273
|
+
if (!origin || allowedOrigins.includes(origin) || allowedOrigins.includes("*")) {
|
|
6274
|
+
cb(null, true);
|
|
6275
|
+
} else {
|
|
6276
|
+
cb(new Error(`CORS: origin ${origin} not allowed`));
|
|
6277
|
+
}
|
|
6278
|
+
},
|
|
6279
|
+
credentials: true
|
|
6280
|
+
}));
|
|
6281
|
+
app.use(express.json({ limit: "1mb" }));
|
|
6282
|
+
const apiLimiter = rateLimit({
|
|
6283
|
+
windowMs: 6e4,
|
|
6284
|
+
max: 300,
|
|
6285
|
+
standardHeaders: true,
|
|
6286
|
+
legacyHeaders: false,
|
|
6287
|
+
message: { error: "Too many requests, please try again later." }
|
|
6288
|
+
});
|
|
6289
|
+
app.use("/api/", apiLimiter);
|
|
6290
|
+
const mutationLimiter = rateLimit({
|
|
6291
|
+
windowMs: 6e4,
|
|
6292
|
+
max: 30,
|
|
6293
|
+
standardHeaders: true,
|
|
6294
|
+
legacyHeaders: false,
|
|
6295
|
+
message: { error: "Too many requests, please slow down." }
|
|
6296
|
+
});
|
|
6297
|
+
app.use(["/api/start", "/api/stop", "/api/auth/login"], mutationLimiter);
|
|
4126
6298
|
const wss = new WebSocketServer({ noServer: true });
|
|
6299
|
+
app.use(createAuthRouter(db));
|
|
6300
|
+
app.use(createEnvRouter());
|
|
6301
|
+
app.use("/api", (req, res, next) => {
|
|
6302
|
+
const PUBLIC_PATHS = ["/auth/login", "/auth/logout", "/setup", "/health"];
|
|
6303
|
+
if (PUBLIC_PATHS.some((p) => req.path === p || req.path.startsWith(p + "/"))) return next();
|
|
6304
|
+
return requireAuth(req, res, next);
|
|
6305
|
+
});
|
|
4127
6306
|
app.use(createApiRouter(db, config));
|
|
4128
6307
|
app.post("/api/intervention/:id", async (req, res) => {
|
|
4129
6308
|
const { id } = req.params;
|
|
@@ -4202,15 +6381,28 @@ async function startWebServer() {
|
|
|
4202
6381
|
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
|
4203
6382
|
}
|
|
4204
6383
|
});
|
|
4205
|
-
app.get("/", (
|
|
6384
|
+
app.get("/login", async (_req, res) => {
|
|
6385
|
+
const count = await db.countUsers();
|
|
6386
|
+
if (count === 0) return res.redirect("/setup");
|
|
6387
|
+
res.send(getLoginHTML());
|
|
6388
|
+
});
|
|
6389
|
+
app.get("/setup", async (_req, res) => {
|
|
6390
|
+
const count = await db.countUsers();
|
|
6391
|
+
if (count > 0) return res.redirect("/login");
|
|
6392
|
+
res.send(getSetupHTML());
|
|
6393
|
+
});
|
|
6394
|
+
app.get("/", authOrRedirect(db), (_req, res) => {
|
|
4206
6395
|
res.send(getDashboardHTML());
|
|
4207
6396
|
});
|
|
4208
|
-
app.get("/kanban", (
|
|
6397
|
+
app.get("/kanban", authOrRedirect(db), (_req, res) => {
|
|
4209
6398
|
res.send(getKanbanHTML());
|
|
4210
6399
|
});
|
|
4211
|
-
app.get("/config", (
|
|
6400
|
+
app.get("/config", authOrRedirect(db), (_req, res) => {
|
|
4212
6401
|
res.send(getConfigHTML(config.getConfigSync()));
|
|
4213
6402
|
});
|
|
6403
|
+
app.get("/config/env", authOrRedirect(db), (_req, res) => {
|
|
6404
|
+
res.send(getEnvHTML());
|
|
6405
|
+
});
|
|
4214
6406
|
const server = app.listen(cfg.web.port, cfg.web.host, () => {
|
|
4215
6407
|
console.log(chalk.cyan("\n\u{1F4CA} OpenQA Status:"));
|
|
4216
6408
|
console.log(chalk.white(` Agent: ${cfg.agent.autoStart ? "Auto-start enabled" : "Idle"}`));
|
|
@@ -4218,6 +6410,7 @@ async function startWebServer() {
|
|
|
4218
6410
|
console.log(chalk.white(` Dashboard: http://localhost:${cfg.web.port}`));
|
|
4219
6411
|
console.log(chalk.white(` Kanban: http://localhost:${cfg.web.port}/kanban`));
|
|
4220
6412
|
console.log(chalk.white(` Config: http://localhost:${cfg.web.port}/config`));
|
|
6413
|
+
console.log(chalk.white(` Env Variables: http://localhost:${cfg.web.port}/config/env`));
|
|
4221
6414
|
console.log(chalk.gray("\nPress Ctrl+C to stop\n"));
|
|
4222
6415
|
if (!cfg.agent.autoStart) {
|
|
4223
6416
|
console.log(chalk.yellow("\u{1F4A1} Auto-start disabled. Agent is idle."));
|