Compare commits

..

31 Commits

Author SHA1 Message Date
oliver 69618bfbd5 Update index.html 2026-06-16 15:08:49 -03:00
oliver a4f0f330ad Update index.html 2026-06-16 14:39:39 -03:00
oliver 62298d1f28 Update index.html 2026-06-16 07:45:26 -03:00
oliver 421215d42d active 2026-06-16 05:44:55 -03:00
oliver 16bec05592 pivot 2026-06-15 19:06:39 -03:00
oliver a5e642bf6d Update app.js 2026-06-15 14:17:12 -03:00
oliver 46d8158c67 new server 2026-06-15 13:35:34 -03:00
oliver 7be5f2a160 Update app.js 2026-06-15 09:59:59 -03:00
oliver 4272ee4f84 Update app.js 2026-06-15 09:59:11 -03:00
oliver de69f1184a Update app.js 2026-06-15 09:40:18 -03:00
oliver 9bdfc0be70 Update app.js 2026-06-15 09:19:27 -03:00
oliver 145079e344 Update app.js 2026-06-15 09:12:22 -03:00
oliver 9448cef053 Update app.js 2026-06-14 20:59:14 -03:00
oliver 4ec38cc43a Update app.js 2026-06-14 20:58:59 -03:00
oliver 99811f9f49 refferal 2026-06-14 20:56:04 -03:00
oliver 897a8b459c Update app.js 2026-06-14 20:34:37 -03:00
oliver 412e93ad83 hold 2026-06-14 20:30:11 -03:00
oliver 677cf54d60 sort 2026-06-14 20:24:16 -03:00
oliver f4c1a5cf96 design 2026-06-14 20:20:36 -03:00
oliver 0502073bd9 Update app.js 2026-06-14 20:13:23 -03:00
oliver cc1bff3be0 crm 2026-06-14 20:10:20 -03:00
oliver 880577e498 Create favicon.ico 2026-06-14 19:58:16 -03:00
oliver 6b8e04f3e0 jj 2026-06-09 19:27:15 -03:00
oliver 230129e05b . 2026-06-09 19:21:14 -03:00
oliver 50479d0755 dash 2026-06-09 19:09:13 -03:00
oliver 0084845480 fix 2026-06-08 17:36:46 -03:00
oliver eacad5ee4e new api 2026-06-08 17:30:59 -03:00
oliver 57c3992d86 copy 2026-06-08 17:00:32 -03:00
oliver 4c9cbe9d2c API Doc 2026-06-08 16:59:32 -03:00
oliver 76401469ad Update styles.css 2026-06-08 09:27:02 -03:00
oliver 14e1c45faf filter app 2026-06-06 06:39:06 -03:00
5 changed files with 3818 additions and 383 deletions
+2494
View File
File diff suppressed because it is too large Load Diff
+583 -109
View File
@@ -1,16 +1,16 @@
/* ─── Constants ──────────────────────────────────────────────────── */
const WEBHOOK_URL =
"https://n8n.derez.ai/webhook/4ad0c89f-ab8c-45ee-bad1-9321ce94dd64";
const INSTANCES_URL =
"https://n8n.derez.ai/webhook/bb369f27-244c-4f8a-869b-f787050619a2";
const BACKUP_URL =
"https://n8n.derez.ai/webhook/69c0c632-df3b-457f-affe-b725f217f9a2";
const SERVERS_URL =
"https://n8n.derez.ai/webhook/78c0c2b8-e497-4d44-8f26-fec34217513c";
const KEYS_URL =
"https://n8n.derez.ai/webhook/a001374f-d8c0-4430-9b47-9f1e9ab134c3";
const AUTH_URL =
"https://n8n.derez.ai/webhook/e256310a-6627-45ba-a221-599751943fe6";
const API_BASE = "https://n8n.derez.ai/webhook";
/* ─── API route paths ────────────────────────────────────────────── */
const ROUTES = {
auth: "/account/login",
dns: "/admin/dns",
instances: "/admin/instance",
backup: "/admin/backup-repo",
servers: "/admin/server",
keys: "/admin/key",
crm: "/crm",
};
/* ─── State ──────────────────────────────────────────────────────── */
let dnsRecords = [];
@@ -20,6 +20,9 @@ let backupRecords = [];
let serversData = [];
let currentServerIdx = 0;
let keysData = [];
let crmRecords = [];
let crmSortKey = null;
let crmSortDir = 1;
let currentSession = null;
let currentEmail = null;
@@ -55,8 +58,11 @@ async function apiFetch(url, options = {}) {
}
/* ─── API helpers ────────────────────────────────────────────────── */
function getApiUrl() {
return localStorage.getItem("al_url") || WEBHOOK_URL;
function getApiBase() {
return (localStorage.getItem("al_url") || API_BASE).replace(/\/$/, "");
}
function apiUrl(route) {
return getApiBase() + route;
}
function apiHeaders() {
return { "Content-Type": "application/json" };
@@ -108,7 +114,7 @@ async function doSignIn() {
btn.innerHTML = `<div class="spinner" style="width:14px;height:14px;"></div> Signing in…`;
try {
const res = await fetch(AUTH_URL, {
const res = await fetch(apiUrl(ROUTES.auth), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
@@ -139,7 +145,7 @@ async function doSignIn() {
/* ─── Sign Out ───────────────────────────────────────────────────── */
function doSignOut() {
if (currentSession) {
fetch(AUTH_URL, {
fetch(apiUrl(ROUTES.auth), {
method: "DELETE",
headers: {
"Content-Type": "application/json",
@@ -163,8 +169,6 @@ function doSignOut() {
function updateSidebarUser() {
document.getElementById("sidebar-username").textContent =
currentEmail || "Not signed in";
const settingsEl = document.getElementById("settings-session-email");
if (settingsEl) settingsEl.textContent = currentEmail || "—";
}
/* ─── Navigation ─────────────────────────────────────────────────── */
@@ -185,10 +189,9 @@ document.querySelectorAll(".nav-item[data-section]").forEach((el) => {
instances: "Instances",
backup: "Backup",
servers: "Servers",
settings: "Settings",
"coming-soon-firewall": "Firewall",
"coming-soon-ssl": "SSL / TLS",
keys: "Keys",
sales: "Sales",
crm: "CRM",
};
document.getElementById("page-title").textContent = labels[key] || key;
@@ -210,6 +213,9 @@ document.querySelectorAll(".nav-item[data-section]").forEach((el) => {
} else if (key === "keys") {
topbar.style.display = "";
btnRefresh.onclick = keysLoadRecords;
} else if (key === "crm") {
topbar.style.display = "";
btnRefresh.onclick = crmLoadRecords;
} else {
topbar.style.display = "none";
}
@@ -227,6 +233,9 @@ document.querySelectorAll(".nav-item[data-section]").forEach((el) => {
if (key === "keys" && keysData.length === 0) {
keysLoadRecords();
}
if (key === "crm" && crmRecords.length === 0) {
crmLoadRecords();
}
});
});
@@ -269,7 +278,7 @@ async function dnsLoadRecords() {
const tbody = document.getElementById("dns-table-body");
tbody.innerHTML = `<tr><td colspan="6"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
try {
const res = await apiFetch(getApiUrl(), {
const res = await apiFetch(apiUrl(ROUTES.dns), {
headers: apiHeaders(),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
@@ -302,7 +311,7 @@ function dnsRender() {
.getElementById("dns-filter-type")
.value.toUpperCase();
const HIDDEN_NAMES = ["fr", "n8n", "admin", "@"];
const HIDDEN_NAMES = ["fr", "n8n", "usa", "admin", "app", "@"];
const filtered = dnsRecords.filter((r) => {
const name = r.name.toLowerCase();
@@ -383,7 +392,7 @@ async function dnsDeleteRecord(btn) {
if (!ok) return;
try {
const res = await apiFetch(getApiUrl(), {
const res = await apiFetch(apiUrl(ROUTES.dns), {
method: "DELETE",
headers: apiHeaders(),
body: JSON.stringify({
@@ -421,7 +430,7 @@ async function dnsAddRecord() {
btn.innerHTML = `<div class="spinner" style="width:14px;height:14px;"></div> Adding…`;
try {
const res = await apiFetch(getApiUrl(), {
const res = await apiFetch(apiUrl(ROUTES.dns), {
method: "POST",
headers: apiHeaders(),
body: JSON.stringify({
@@ -452,13 +461,17 @@ function settingsSave() {
const url = document.getElementById("setting-url").value.trim();
if (url) {
localStorage.setItem("al_url", url);
toast("Webhook URL saved", "success");
} else toast("Enter a URL to save", "warning");
toast("API base URL saved", "success");
} else {
localStorage.removeItem("al_url");
document.getElementById("setting-url").value = "";
toast("Reverted to default: " + API_BASE, "info");
}
}
async function settingsTest() {
try {
const res = await apiFetch(getApiUrl(), {
const res = await apiFetch(apiUrl(ROUTES.dns), {
headers: apiHeaders(),
});
if (res.ok) toast("Connection OK — HTTP " + res.status, "success");
@@ -519,9 +532,11 @@ function stripQuotes(s) {
/* ─── Instances — Load ───────────────────────────────────────── */
async function instancesLoadRecords() {
const tbody = document.getElementById("inst-table-body");
tbody.innerHTML = `<tr><td colspan="9"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
tbody.innerHTML = `<tr><td colspan="8"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
try {
const res = await apiFetch(INSTANCES_URL, { headers: apiHeaders() });
const res = await apiFetch(apiUrl(ROUTES.instances), {
headers: apiHeaders(),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const json = await res.json();
// Normalise: [{data:[...]}, ...], plain array, {data:[...]}, or single object
@@ -551,12 +566,10 @@ function instancesRender() {
const filtered = instancesRecords.filter((r) => {
if (!search) return true;
return (
stripQuotes(r.uuid).toLowerCase().includes(search) ||
stripQuotes(r.agent).toLowerCase().includes(search) ||
stripQuotes(r.domain).toLowerCase().includes(search) ||
stripQuotes(r.server ?? "")
.toLowerCase()
.includes(search)
r.uuid?.toLowerCase().includes(search) ||
r.agent?.toLowerCase().includes(search) ||
r.email?.toLowerCase().includes(search) ||
(r.server ?? "").toLowerCase().includes(search)
);
});
@@ -567,7 +580,7 @@ function instancesRender() {
const tbody = document.getElementById("inst-table-body");
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="9"><div class="empty-state">
tbody.innerHTML = `<tr><td colspan="8"><div class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
@@ -579,43 +592,28 @@ function instancesRender() {
tbody.innerHTML = filtered
.map((r) => {
const uuid = escHtml(r.uuid);
const agent = escHtml(stripQuotes(r.agent));
const domain = escHtml(stripQuotes(r.domain));
const sshPort = escHtml(stripQuotes(r.ssh_port));
const ram = escHtml(stripQuotes(r.ram));
const cpu = escHtml(stripQuotes(r.cpu));
const server = escHtml(stripQuotes(r.server ?? ""));
const created = escHtml(stripQuotes(r.created ?? ""));
const uuid = escHtml(r.UUID ?? "");
const agent = escHtml(r.agent);
const product = escHtml(r.product ?? "");
const server = escHtml(r.server ?? "");
const email = escHtml(r.email ?? "");
const period = escHtml(r.period ?? "");
const sshStatus = r.SSH ? "✓" : "";
const backupStatus = r.BACKUP ? "B" : "";
const createdStatus = r.created ? "C" : "";
const statusParts = [sshStatus, backupStatus, createdStatus].filter(
Boolean,
);
const status = statusParts.length ? statusParts.join(" ") : "—";
return `<tr>
<td><span class="mono">${uuid}</span></td>
<td>${agent}</td>
<td><span class="mono">${domain}</span></td>
<td class="mono">${sshPort}</td>
<td>${ram}</td>
<td>${cpu}</td>
<td>${product}</td>
<td>${server}</td>
<td>${created}</td>
<td>${email}</td>
<td>${period}</td>
<td><span class="mono">${status}</span></td>
<td style="white-space:nowrap; display:flex; gap:6px;">
<button class="btn btn-ghost btn-sm"
data-domain="${domain}"
data-port="${sshPort}"
onclick="instancesCopySSH(this)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
Copy SSH
</button>
<button class="btn btn-ghost btn-sm"
data-uuid="${uuid}"
onclick="instancesOpenEdit(this)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
Edit
</button>
<button class="btn btn-danger btn-sm"
data-uuid="${uuid}"
data-agent="${agent}"
@@ -663,7 +661,7 @@ async function instancesDelete(btn) {
if (!ok) return;
try {
const res = await apiFetch(INSTANCES_URL, {
const res = await apiFetch(apiUrl(ROUTES.instances), {
method: "DELETE",
headers: apiHeaders(),
body: JSON.stringify({ uuid }),
@@ -721,7 +719,7 @@ async function instancesSaveEdit() {
btn.innerHTML = `<div class="spinner" style="width:14px;height:14px;"></div> Saving…`;
try {
const res = await apiFetch(INSTANCES_URL, {
const res = await apiFetch(apiUrl(ROUTES.instances), {
method: "PUT",
headers: apiHeaders(),
body: JSON.stringify({
@@ -753,7 +751,9 @@ async function backupLoadRecords() {
const tbody = document.getElementById("backup-table-body");
tbody.innerHTML = `<tr><td colspan="7"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
try {
const res = await apiFetch(BACKUP_URL, { headers: apiHeaders() });
const res = await apiFetch(apiUrl(ROUTES.backup), {
headers: apiHeaders(),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const json = await res.json();
// Normalise:
@@ -848,7 +848,7 @@ async function backupDelete(btn) {
if (!ok) return;
try {
const res = await apiFetch(BACKUP_URL, {
const res = await apiFetch(apiUrl(ROUTES.backup), {
method: "DELETE",
headers: apiHeaders(),
body: JSON.stringify({ id }),
@@ -886,19 +886,49 @@ async function serversLoadData() {
document.getElementById("server-dashboard").innerHTML =
`<div class="empty-state"><div class="spinner"></div><p style="margin-top:12px">Loading…</p></div>`;
try {
const res = await apiFetch(SERVERS_URL, { headers: apiHeaders() });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const json = await res.json();
const raw = Array.isArray(json) ? json : [json];
serversData = raw
.map((item) => {
try {
return typeof item.text === "string" ? JSON.parse(item.text) : item;
} catch {
return null;
/* ─── Step 1: Fetch server list with seats ──────────────── */
const seatRes = await fetch("https://n8n.derez.ai/webhook/server");
if (!seatRes.ok) throw new Error(`Seat API HTTP ${seatRes.status}`);
const seatJson = await seatRes.json();
const seatList = Array.isArray(seatJson?.data)
? seatJson.data
: Array.isArray(seatJson)
? seatJson
: [];
if (!seatList.length) throw new Error("No servers in seat endpoint");
/* ─── Step 2: Fetch full dashboard for each server ─────── */
serversData = [];
for (const seat of seatList) {
const serverName = seat.server;
try {
const res = await apiFetch(
apiUrl(ROUTES.servers) + "?server=" + encodeURIComponent(serverName),
{
headers: apiHeaders(),
},
);
if (res.ok) {
const json = await res.json();
const raw = Array.isArray(json) ? json : [json];
for (const item of raw) {
let parsed;
try {
parsed =
typeof item.text === "string" ? JSON.parse(item.text) : item;
} catch {
continue;
}
if (!parsed) continue;
parsed.seats = seat;
serversData.push(parsed);
}
}
})
.filter(Boolean);
} catch (_) {
/* skip servers that fail */
}
}
if (serversData.length === 0) throw new Error("No server data in response");
currentServerIdx = 0;
serversRenderTabs();
@@ -932,7 +962,9 @@ function serversRenderTabs() {
.map((s, i) => {
const status = s.summary?.overall_status ?? "unknown";
const color = dotColor[status] ?? "var(--text-muted)";
const name = escHtml(s.server?.name ?? `Server ${i + 1}`);
const name = escHtml(
s.server?.name ?? s.seats?.server ?? `Server ${i + 1}`,
);
return `<div class="server-tab${i === currentServerIdx ? " active" : ""}" onclick="serversSelectTab(${i})">
<span class="server-tab-dot" style="background:${color}"></span>
${name}
@@ -1059,7 +1091,7 @@ function serversRenderDashboard(idx) {
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:14px">
<div>
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
<span style="font-size:20px;font-weight:700">${escHtml(server.name)}</span>
<span style="font-size:20px;font-weight:700">${escHtml(server.name ?? s.seats?.server)}</span>
<span class="badge badge-${escHtml(summary.overall_status)}">${summary.overall_status.toUpperCase()}</span>
</div>
<div class="text-muted" style="font-size:12px">
@@ -1071,9 +1103,6 @@ function serversRenderDashboard(idx) {
<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.07em;margin-top:3px">Score</div>
</div>
</div>
<p style="font-size:13px;color:var(--text-muted);line-height:1.65;border-top:1px solid var(--border);padding-top:12px;margin:0">
${escHtml(summary.short_summary)}
</p>
</div>
<!-- ─ CPU / Memory / Disk ───────────────────────────────── -->
@@ -1137,7 +1166,7 @@ function serversRenderDashboard(idx) {
<!-- ─ Security + Containers ──────────────────────────────── -->
<div class="dash-grid">
<div class="card dash-span-2">
<div class="card">
<div class="card-title" style="margin-bottom:14px">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
@@ -1155,26 +1184,46 @@ function serversRenderDashboard(idx) {
<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.07em;margin-bottom:6px">Exposed Ports</div>
<div>${portsHtml}</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:8px">Vuln score: ${security.vulnerability_score ?? "—"}</div>
</div>
</div>
</div>
${
security.risk_flags?.length
? `
<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--border);display:flex;gap:6px;flex-wrap:wrap">
${security.risk_flags.map((f) => `<span class="badge badge-medium">${escHtml(f)}</span>`).join("")}
</div>`
: ""
}
</div>
<div class="card">
<div class="card-title" style="margin-bottom:14px">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
Containers
<div class="card">
<div class="card-title" style="margin-bottom:14px">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 3v18"/>
</svg>
Seats
</div>
${
s.seats
? `
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:14px">
<div>
<div class="dash-stat">${s.seats.Seats ?? "—"}</div>
<div class="dash-stat-sub">Total</div>
</div>
<div>
<div class="dash-stat">${s.seats["Seats taken"] ?? "—"}</div>
<div class="dash-stat-sub">Taken</div>
</div>
<div>
<div class="dash-stat" style="color:${(s.seats["Seats Available"] ?? 0) < 2 ? "var(--danger)" : (s.seats["Seats Available"] ?? 0) < 10 ? "var(--warning)" : "var(--success)"}">${s.seats["Seats Available"] ?? "—"}</div>
<div class="dash-stat-sub">Available</div>
</div>
</div>`
: `<div class="text-muted" style="font-size:13px;padding:8px 0">No seat data available</div>`
}
</div>
<div class="card">
<div class="card-title" style="margin-bottom:14px">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
Containers
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
<div>
<div class="dash-stat" style="color:${containers.running > 0 ? "var(--success)" : "var(--text-muted)"}">${containers.running}</div>
@@ -1255,7 +1304,7 @@ async function keysLoadRecords() {
const tbody = document.getElementById("keys-table-body");
tbody.innerHTML = `<tr><td colspan="8"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
try {
const res = await apiFetch(KEYS_URL, { headers: apiHeaders() });
const res = await apiFetch(apiUrl(ROUTES.keys), { headers: apiHeaders() });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
// Support both [{data:[...]}, ...] and {data:[...]} and flat array
@@ -1335,7 +1384,7 @@ async function keysDelete(btn) {
if (!ok) return;
try {
const res = await apiFetch(KEYS_URL, {
const res = await apiFetch(apiUrl(ROUTES.keys), {
method: "DELETE",
headers: apiHeaders(),
body: JSON.stringify({ name }),
@@ -1348,6 +1397,431 @@ async function keysDelete(btn) {
}
}
/* ─── CRM — Load ────────────────────────────────────────────────── */
async function crmLoadRecords() {
const tbody = document.getElementById("crm-table-body");
tbody.innerHTML = `<tr><td colspan="9"><div class="empty-state"><div class="spinner"></div><p style="margin-top:12px;">Loading…</p></div></td></tr>`;
try {
const res = await apiFetch(apiUrl(ROUTES.crm), {
headers: apiHeaders(),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const json = await res.json();
// Shape: {data: [{...}, ...]}
let data = json?.data ?? json?.response ?? json;
// If the outer result wraps in an extra array like [{data:[...]}] handle that too
if (Array.isArray(data) && data.length > 0 && data[0]?.data) {
data = data[0].data;
}
crmRecords = Array.isArray(data) ? data : [];
crmRender();
toast(
`Loaded ${crmRecords.length} contact${crmRecords.length !== 1 ? "s" : ""}`,
"success",
);
} catch (err) {
crmRecords = [];
tbody.innerHTML = `<tr><td colspan="9"><div class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<p style="margin-top:10px; color:var(--danger);">${err.message}</p>
</div></td></tr>`;
toast("Failed to load CRM contacts: " + err.message, "error");
}
}
/* ─── CRM — Render table ────────────────────────────────────────── */
function crmRender() {
const searchName = (
document.getElementById("crm-search-name")?.value ?? ""
).toLowerCase();
const searchCompany = (
document.getElementById("crm-search-company")?.value ?? ""
).toLowerCase();
const searchEmail = (
document.getElementById("crm-search-email")?.value ?? ""
).toLowerCase();
const searchStage = (
document.getElementById("crm-search-stage")?.value ?? ""
).toLowerCase();
const searchStrategy = (
document.getElementById("crm-search-strategy")?.value ?? ""
).toLowerCase();
const searchCategory = (
document.getElementById("crm-search-category")?.value ?? ""
).toLowerCase();
const searchAffiliate = (
document.getElementById("crm-search-affiliate")?.value ?? ""
).toLowerCase();
let filtered = crmRecords.filter((r) => {
const name = (r.Name ?? "").toLowerCase();
const company = (r.Company ?? "").toLowerCase();
const email = (r.email ?? "").toLowerCase();
const stage = (r.Stage ?? "").toLowerCase();
const strategy = (r.strategy ?? "").toLowerCase();
const category = (r.category ?? "").toLowerCase();
const affiliate = (r.affiliate ?? "").toLowerCase();
return (
(!searchName || name.includes(searchName)) &&
(!searchCompany || company.includes(searchCompany)) &&
(!searchEmail || email.includes(searchEmail)) &&
(!searchStage || stage.includes(searchStage)) &&
(!searchStrategy || strategy.includes(searchStrategy)) &&
(!searchCategory || category.includes(searchCategory)) &&
(!searchAffiliate || affiliate.includes(searchAffiliate))
);
});
// Sort
if (crmSortKey) {
filtered.sort((a, b) => {
const va = (a[crmSortKey] ?? "").toString().toLowerCase();
const vb = (b[crmSortKey] ?? "").toString().toLowerCase();
if (va < vb) return -1 * crmSortDir;
if (va > vb) return 1 * crmSortDir;
return 0;
});
}
// Update sort arrows
document.querySelectorAll(".sort-arrow").forEach((el) => {
el.textContent =
el.dataset.sort === crmSortKey ? (crmSortDir === 1 ? " ▲" : " ▼") : "";
});
const total = crmRecords.length;
const countEl = document.getElementById("crm-record-count");
countEl.textContent =
filtered.length === total
? `${total} contact${total !== 1 ? "s" : ""}`
: `${filtered.length} / ${total} contacts`;
// Compute category counts from all records (unfiltered)
const catCounts = {};
for (const r of crmRecords) {
const cat = (r.category || "Uncategorized").trim();
catCounts[cat] = (catCounts[cat] || 0) + 1;
}
const catContainer = document.getElementById("crm-category-counts");
if (catContainer) {
catContainer.innerHTML = Object.entries(catCounts)
.sort((a, b) => b[1] - a[1])
.map(
([cat, cnt]) =>
`<span style="font-size:11px;color:var(--text-muted);background:var(--bg-input);border:1px solid var(--border);border-radius:4px;padding:1px 6px;white-space:nowrap">${escHtml(cat)}: ${cnt}</span>`,
)
.join("");
}
// Reset check-all state
const checkAll = document.getElementById("crm-check-all");
if (checkAll) checkAll.checked = false;
const tbody = document.getElementById("crm-table-body");
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="9"><div class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<p>No contacts found</p>
</div></td></tr>`;
document.getElementById("crm-delete-selected-btn").disabled = true;
return;
}
tbody.innerHTML = filtered
.map((r) => {
const id = r.id;
const name = escHtml(r.Name ?? "");
const company = escHtml(r.Company ?? "");
const email = escHtml(r.email ?? "");
const stage = escHtml(r.Stage ?? "");
const strategy = escHtml(r.strategy ?? "");
const category = escHtml(r.category ?? "");
const affiliate = escHtml(r.affiliate ?? "");
const dateNext = r.date_next_contact
? formatDate(r.date_next_contact)
: "—";
// Prevent row click when clicking the checkbox
return `<tr data-crm-id="${id}" style="cursor:pointer;" onclick="crmOpenEdit(${id})">
<td style="cursor:default;" onclick="event.stopPropagation()"><input type="checkbox" class="crm-select" data-id="${id}" onchange="crmUpdateDeleteBtn()" /></td>
<td><strong>${name || "—"}</strong></td>
<td>${company || "—"}</td>
<td><a href="mailto:${email}" style="color:var(--accent)" onclick="event.stopPropagation()">${email || "—"}</a></td>
<td><span class="badge ${stage === "Hold" ? "badge-other" : "badge-green"}">${stage || "—"}</span></td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${strategy}">${strategy || "—"}</td>
<td>${category ? escHtml(category) : "—"}</td>
<td>${affiliate || "—"}</td>
<td class="text-muted">${dateNext}</td>
</tr>`;
})
.join("");
crmUpdateDeleteBtn();
}
/* ─── CRM — Clear all filters ──────────────────────────────────── */
function crmClearFilters() {
[
"name",
"company",
"email",
"stage",
"strategy",
"category",
"affiliate",
].forEach((f) => {
const el = document.getElementById("crm-search-" + f);
if (el) el.value = "";
});
crmRender();
}
/* ─── CRM — Sort ───────────────────────────────────────────────── */
function crmToggleSort(key) {
if (crmSortKey === key) {
crmSortDir *= -1;
} else {
crmSortKey = key;
crmSortDir = 1;
}
crmRender();
}
/* ─── CRM — Checkbox helpers ────────────────────────────────────── */
function crmUpdateDeleteBtn() {
const checked = document.querySelectorAll(".crm-select:checked").length;
const hasSel = checked > 0;
document.getElementById("crm-delete-selected-btn").disabled = !hasSel;
document.getElementById("crm-hold-btn").disabled = !hasSel;
document.getElementById("crm-active-btn").disabled = !hasSel;
const label = hasSel ? `Delete Checked (${checked})` : "Delete Checked";
document.getElementById("crm-delete-selected-btn").innerHTML =
`<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;vertical-align:middle">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg> ${label}`;
document.getElementById("crm-hold-btn").textContent = hasSel
? `Set Hold (${checked})`
: "Set Hold";
document.getElementById("crm-active-btn").textContent = hasSel
? `Set Active (${checked})`
: "Set Active";
}
function crmToggleAll() {
const checked = document.getElementById("crm-check-all").checked;
document.querySelectorAll(".crm-select").forEach((cb) => {
cb.checked = checked;
});
crmUpdateDeleteBtn();
}
/* ─── CRM — Delete single contact ──────────────────────────────── */
async function crmDeleteContact(btn) {
const id = btn.dataset.id;
const name = btn.dataset.name || `#${id}`;
const ok = await confirmDialog(
"Delete Contact",
`Delete contact “${name}” (id: ${id})? This cannot be undone.`,
);
if (!ok) return;
try {
const res = await apiFetch(apiUrl(ROUTES.crm), {
method: "DELETE",
headers: apiHeaders(),
body: JSON.stringify({ id: Number(id) }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
toast(`Contact “${name}” deleted`, "success");
await crmLoadRecords();
} catch (err) {
toast("Delete failed: " + err.message, "error");
}
}
/* ─── CRM — Delete selected contacts ────────────────────────────── */
async function crmDeleteSelected() {
const ids = Array.from(document.querySelectorAll(".crm-select:checked")).map(
(cb) => Number(cb.dataset.id),
);
if (ids.length === 0) return;
const ok = await confirmDialog(
"Delete Selected Contacts",
`Delete ${ids.length} contact${ids.length !== 1 ? "s" : ""}? This cannot be undone.`,
);
if (!ok) return;
// Delete sequentially
let failed = 0;
for (const id of ids) {
try {
const res = await apiFetch(apiUrl(ROUTES.crm), {
method: "DELETE",
headers: apiHeaders(),
body: JSON.stringify({ id }),
});
if (!res.ok) failed++;
} catch {
failed++;
}
}
if (failed === 0) {
toast(
`Deleted ${ids.length} contact${ids.length !== 1 ? "s" : ""}`,
"success",
);
} else {
toast(
`Deleted ${ids.length - failed} contact${ids.length - failed !== 1 ? "s" : ""} (${failed} failed)`,
"warning",
);
}
await crmLoadRecords();
}
/* ─── CRM — Set stage for checked contacts ──────────────────────── */
async function crmSetStageSelected(stage) {
const ids = Array.from(document.querySelectorAll(".crm-select:checked")).map(
(cb) => Number(cb.dataset.id),
);
if (ids.length === 0) return;
const ok = await confirmDialog(
`Set Stage to ${stage}`,
`Set stage to “${stage}” for ${ids.length} contact${ids.length !== 1 ? "s" : ""}?`,
stage,
);
if (!ok) return;
let failed = 0;
for (const id of ids) {
const r = crmRecords.find((c) => c.id === id);
if (!r) {
failed++;
continue;
}
const payload = {
id,
Name: r.Name ?? "",
Company: r.Company ?? "",
email: r.email ?? "",
Stage: stage,
strategy: r.strategy ?? "",
category: r.category ?? "",
affiliate: r.affiliate ?? "",
Number: r.Number ?? "",
date_next_contact: r.date_next_contact ?? null,
History: r.History ?? "",
};
try {
const res = await apiFetch(apiUrl(ROUTES.crm), {
method: "POST",
headers: apiHeaders(),
body: JSON.stringify(payload),
});
if (!res.ok) failed++;
} catch {
failed++;
}
}
if (failed === 0) {
toast(
`Stage set to “${stage}” for ${ids.length} contact${ids.length !== 1 ? "s" : ""}`,
"success",
);
} else {
toast(
`Updated ${ids.length - failed} contact${ids.length - failed !== 1 ? "s" : ""} (${failed} failed)`,
"warning",
);
}
await crmLoadRecords();
}
/* ─── CRM — Edit modal ──────────────────────────────────────────── */
function crmOpenEdit(id) {
const r = crmRecords.find((c) => c.id === id);
if (!r) return;
document.getElementById("crm-edit-id").value = r.id;
document.getElementById("crm-edit-name").value = r.Name ?? "";
document.getElementById("crm-edit-company").value = r.Company ?? "";
document.getElementById("crm-edit-email").value = r.email ?? "";
document.getElementById("crm-edit-stage").value = r.Stage ?? "";
document.getElementById("crm-edit-strategy").value = r.strategy ?? "";
document.getElementById("crm-edit-category").value = r.category ?? "";
document.getElementById("crm-edit-affiliate").value = r.affiliate ?? "";
document.getElementById("crm-edit-number").value = r.Number ?? "";
document.getElementById("crm-edit-date-next").value = r.date_next_contact
? r.date_next_contact.split("T")[0]
: "";
document.getElementById("crm-edit-history").value = r.History ?? "";
document.getElementById("crm-edit-title").textContent =
`Edit Contact — ${r.Name || r.email || `#${r.id}`}`;
document.getElementById("crm-edit-backdrop").classList.add("open");
setTimeout(() => document.getElementById("crm-edit-name").focus(), 80);
}
function crmCloseEdit() {
document.getElementById("crm-edit-backdrop").classList.remove("open");
}
document.getElementById("crm-edit-backdrop")?.addEventListener("click", (e) => {
if (e.target === e.currentTarget) crmCloseEdit();
});
async function crmSaveEdit() {
const id = Number(document.getElementById("crm-edit-id").value);
const payload = {
id,
Name: document.getElementById("crm-edit-name").value.trim(),
Company: document.getElementById("crm-edit-company").value.trim(),
email: document.getElementById("crm-edit-email").value.trim(),
Stage: document.getElementById("crm-edit-stage").value,
strategy: document.getElementById("crm-edit-strategy").value.trim(),
category: document.getElementById("crm-edit-category").value.trim(),
affiliate: document.getElementById("crm-edit-affiliate").value.trim(),
Number: document.getElementById("crm-edit-number").value.trim(),
date_next_contact:
document.getElementById("crm-edit-date-next").value || null,
History: document.getElementById("crm-edit-history").value.trim(),
};
const btn = document.getElementById("crm-save-btn");
btn.disabled = true;
const orig = btn.innerHTML;
btn.innerHTML = `<div class="spinner" style="width:14px;height:14px;"></div> Saving…`;
try {
const res = await apiFetch(apiUrl(ROUTES.crm), {
method: "POST",
headers: apiHeaders(),
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
toast(
`Contact “${payload.Name || payload.email || `#${id}`}” updated`,
"success",
);
crmCloseEdit();
await crmLoadRecords();
} catch (err) {
toast("Save failed: " + err.message, "error");
} finally {
btn.disabled = false;
btn.innerHTML = orig;
}
}
/* ─── Init ────────────────────────────────────────────────────────── */
settingsLoad();
const _boot = getCookie("al_session");
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+623 -201
View File
@@ -27,42 +27,8 @@
derez.ai
</div>
<div class="sidebar-section-label">Network</div>
<div class="nav-item active" data-section="dns">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3" />
</svg>
DNS Records
</div>
<div class="nav-item" data-section="coming-soon-firewall">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
Firewall
</div>
<div class="sidebar-section-label">Infrastructure</div>
<div class="nav-item" data-section="instances">
<div class="nav-item active" data-section="instances">
<svg
width="16"
height="16"
@@ -81,6 +47,23 @@
</svg>
Instances
</div>
<div class="nav-item" data-section="dns">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3" />
</svg>
DNS Records
</div>
<div class="nav-item" data-section="backup">
<svg
width="16"
@@ -98,6 +81,70 @@
</svg>
Backup
</div>
<div class="nav-item" data-section="keys">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"
/>
</svg>
Keys
</div>
<div
class="sidebar-section-label"
style="
margin-top: 28px;
padding-top: 12px;
border-top: 1px solid var(--border);
"
>
Dashboards
</div>
<div class="nav-item" data-section="crm">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
CRM
</div>
<div class="nav-item" data-section="sales">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
Sales
</div>
<div class="nav-item" data-section="servers">
<svg
width="16"
@@ -116,41 +163,6 @@
</svg>
Servers
</div>
<div class="nav-item" data-section="coming-soon-ssl">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
SSL / TLS
</div>
<div class="sidebar-section-label">Commercial</div>
<div class="nav-item" data-section="keys">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"
/>
</svg>
Keys
</div>
<div class="sidebar-footer">
<div
@@ -182,24 +194,6 @@
</svg>
<span id="sidebar-username">Not signed in</span>
</div>
<div class="nav-item" data-section="settings">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.07 4.93a10 10 0 0 0-14.14 0M4.93 19.07a10 10 0 0 0 14.14 0M12 2v2M12 20v2M2 12h2M20 12h2"
/>
</svg>
Settings
</div>
<div
class="nav-item"
onclick="doSignOut()"
@@ -265,7 +259,7 @@
<!-- Content -->
<div class="content">
<!-- ─── DNS Section ───────────────────────────────────────── -->
<div class="section active" id="section-dns">
<div class="section" id="section-dns">
<!-- Filter / search bar -->
<div
class="card"
@@ -483,7 +477,7 @@
</div>
<!-- ─── Instances Section ──────────────────────────────────── -->
<div class="section" id="section-instances">
<div class="section active" id="section-instances">
<!-- Search bar -->
<div
class="card"
@@ -551,18 +545,17 @@
<tr>
<th>UUID</th>
<th>Agent</th>
<th>Domain</th>
<th>SSH Port</th>
<th>RAM</th>
<th>CPU</th>
<th>Product</th>
<th>Server</th>
<th>Created</th>
<th>Email</th>
<th>Period</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="inst-table-body">
<tr>
<td colspan="9">
<td colspan="8">
<div class="empty-state">
<div class="spinner"></div>
<p style="margin-top: 12px">
@@ -665,67 +658,6 @@
</div>
</div>
<!-- ─── Settings Section ──────────────────────────────────── -->
<div class="section" id="section-settings">
<div class="card">
<div class="card-header">
<span class="card-title">API Connection</span>
</div>
<div class="settings-grid">
<div
class="form-group"
style="grid-column: 1/-1"
>
<label>Webhook Base URL</label>
<input
type="text"
id="setting-url"
placeholder="https://…"
/>
</div>
</div>
<div style="margin-top: 16px">
<button
class="btn btn-primary"
onclick="settingsSave()"
>
Save URL
</button>
<button
class="btn btn-ghost"
onclick="settingsTest()"
style="margin-left: 8px"
>
Test Connection
</button>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Current Session</span>
</div>
<p
style="
font-size: 13px;
color: var(--text-muted);
margin-bottom: 16px;
"
>
Signed in as
<strong id="settings-session-email"></strong>.
Sign out to switch accounts.
</p>
<div style="margin-top: 16px">
<button
class="btn btn-danger"
onclick="doSignOut()"
>
Sign Out
</button>
</div>
</div>
</div>
<!-- ─── Keys Section ───────────────────────────────────── -->
<div class="section" id="section-keys">
<div class="card">
@@ -785,25 +717,380 @@
</div>
</div>
<!-- ─── Coming-soon placeholders ─────────────────────────── -->
<div class="section" id="section-coming-soon-firewall">
<!-- ─── CRM Section ─────────────────────────── -->
<div class="section" id="section-crm">
<!-- Action bar -->
<div
class="card"
style="padding: 10px 20px; margin-bottom: 16px"
>
<div
class="form-row"
style="
flex-wrap: wrap;
gap: 8px;
align-items: center;
"
>
<div class="form-group" style="flex: 0 0 auto">
<button
class="btn btn-sm"
id="crm-hold-btn"
onclick="crmSetStageSelected('Hold')"
disabled
style="
white-space: nowrap;
background: var(--accent-dim);
color: var(--accent);
border: 1px solid var(--border-hi);
"
>
Set Hold
</button>
</div>
<div class="form-group" style="flex: 0 0 auto">
<button
class="btn btn-sm"
id="crm-active-btn"
onclick="crmSetStageSelected('Active')"
disabled
style="
white-space: nowrap;
background: var(--accent-dim);
color: var(--accent);
border: 1px solid var(--border-hi);
"
>
Set Active
</button>
</div>
<div class="form-group" style="flex: 0 0 auto">
<button
class="btn btn-danger btn-sm"
id="crm-delete-selected-btn"
onclick="crmDeleteSelected()"
disabled
style="white-space: nowrap"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"
/>
<path d="M10 11v6M14 11v6" />
<path
d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"
/>
</svg>
Delete Checked
</button>
</div>
<div
class="form-group"
style="
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 4px;
"
id="crm-category-counts"
></div>
<span style="flex: 1; min-width: 0"></span>
</div>
</div>
<div class="card">
<div class="empty-state">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
<div class="card-header">
<span class="card-title">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"
/>
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
CRM Contacts
</span>
<span
id="crm-record-count"
class="text-muted"
style="font-size: 12px"
></span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width: 36px">
<input
type="checkbox"
id="crm-check-all"
onchange="crmToggleAll()"
title="Select / deselect all"
/>
</th>
<th
onclick="crmToggleSort('Name')"
style="
cursor: pointer;
user-select: none;
"
>
Name
<span
class="sort-arrow"
data-sort="Name"
></span>
</th>
<th
onclick="
crmToggleSort('Company')
"
style="
cursor: pointer;
user-select: none;
"
>
Company
<span
class="sort-arrow"
data-sort="Company"
></span>
</th>
<th
onclick="crmToggleSort('email')"
style="
cursor: pointer;
user-select: none;
"
>
Email
<span
class="sort-arrow"
data-sort="email"
></span>
</th>
<th
onclick="crmToggleSort('Stage')"
style="
cursor: pointer;
user-select: none;
"
>
Stage
<span
class="sort-arrow"
data-sort="Stage"
></span>
</th>
<th
onclick="
crmToggleSort('strategy')
"
style="
cursor: pointer;
user-select: none;
"
>
Strategy
<span
class="sort-arrow"
data-sort="strategy"
></span>
</th>
<th
onclick="
crmToggleSort('category')
"
style="
cursor: pointer;
user-select: none;
"
>
Category
<span
class="sort-arrow"
data-sort="category"
></span>
</th>
<th
onclick="
crmToggleSort('affiliate')
"
style="
cursor: pointer;
user-select: none;
"
>
Referral
<span
class="sort-arrow"
data-sort="affiliate"
></span>
</th>
<th
onclick="
crmToggleSort(
'date_next_contact',
)
"
style="
cursor: pointer;
user-select: none;
"
>
Date Next Contact
<span
class="sort-arrow"
data-sort="date_next_contact"
></span>
</th>
</tr>
</thead>
<tr class="crm-search-row">
<th></th>
<th>
<input
type="text"
id="crm-search-name"
placeholder="Filter Name…"
oninput="crmRender()"
class="crm-filter-input"
/>
</th>
<th>
<input
type="text"
id="crm-search-company"
placeholder="Filter Company…"
oninput="crmRender()"
class="crm-filter-input"
/>
</th>
<th>
<input
type="text"
id="crm-search-email"
placeholder="Filter Email…"
oninput="crmRender()"
class="crm-filter-input"
/>
</th>
<th>
<input
type="text"
id="crm-search-stage"
placeholder="Filter Stage…"
oninput="crmRender()"
class="crm-filter-input"
/>
</th>
<th>
<input
type="text"
id="crm-search-strategy"
placeholder="Filter Strategy…"
oninput="crmRender()"
class="crm-filter-input"
/>
</th>
<th>
<input
type="text"
id="crm-search-category"
placeholder="Filter Category…"
oninput="crmRender()"
class="crm-filter-input"
/>
</th>
<th style="white-space: nowrap">
<input
type="text"
id="crm-search-affiliate"
placeholder="Filter Referral…"
oninput="crmRender()"
class="crm-filter-input"
/>
<button
class="btn btn-ghost btn-sm"
onclick="crmClearFilters()"
title="Clear all filters"
>
</button>
</th>
</tr>
<tbody id="crm-table-body">
<tr>
<td colspan="9">
<div class="empty-state">
<div class="spinner"></div>
<p style="margin-top: 12px">
Loading contacts…
</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ─── Sales Dashboard ─────────────────────────── -->
<div class="section" id="section-sales">
<div class="card">
<div class="card-header">
<span class="card-title">Sales Dashboard</span>
</div>
<div style="padding: 16px 0 0">
<iframe
src="https://derez.ai/operations.html"
style="
width: 100%;
height: 80vh;
border: none;
border-radius: 6px;
background: #fff;
"
title="Sales Dashboard"
allow="clipboard-read; clipboard-write"
></iframe>
<p
style="
font-size: 12px;
color: var(--text-muted);
margin-top: 10px;
text-align: center;
"
>
<path
d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
/>
</svg>
<p>Firewall management — coming soon</p>
<a
href="https://derez.ai/operations.html"
target="_blank"
style="color: var(--accent)"
>
Open in new tab ↗
</a>
</p>
</div>
</div>
</div>
@@ -817,33 +1104,6 @@
</div>
</div>
</div>
<div class="section" id="section-coming-soon-ssl">
<div class="card">
<div class="empty-state">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<p>SSL / TLS management — coming soon</p>
</div>
</div>
</div>
</div>
<!-- /content -->
</div>
@@ -982,6 +1242,168 @@
</div>
</div>
<!-- ═══ CRM EDIT MODAL ════════════════════════════════════ -->
<div class="modal-backdrop" id="crm-edit-backdrop">
<div class="modal" style="max-width: 640px">
<div class="modal-header">
<span class="modal-title" id="crm-edit-title"
>Edit Contact</span
>
<button
class="modal-close"
onclick="crmCloseEdit()"
aria-label="Close"
>
&#x2715;
</button>
</div>
<div class="modal-body">
<input type="hidden" id="crm-edit-id" />
<div class="form-row" style="flex-wrap: wrap">
<div
class="form-group"
style="flex: 1; min-width: 160px"
>
<label>Name</label>
<input
type="text"
id="crm-edit-name"
placeholder="Contact name"
/>
</div>
<div
class="form-group"
style="flex: 1; min-width: 160px"
>
<label>Company</label>
<input
type="text"
id="crm-edit-company"
placeholder="Company"
/>
</div>
<div
class="form-group"
style="flex: 1; min-width: 200px"
>
<label>Email</label>
<input
type="email"
id="crm-edit-email"
placeholder="email@example.com"
/>
</div>
</div>
<div class="form-row" style="flex-wrap: wrap">
<div
class="form-group"
style="flex: 1; min-width: 120px"
>
<label>Stage</label>
<select id="crm-edit-stage">
<option value="">— Select —</option>
<option>Active</option>
<option>Hold</option>
<option>Blacklist</option>
<option>Won</option>
<option>Lost</option>
<option>Contacted</option>
<option>Cold</option>
<option>Warm</option>
<option>Bounced</option>
</select>
</div>
<div
class="form-group"
style="flex: 1; min-width: 160px"
>
<label>Strategy</label>
<input
type="text"
id="crm-edit-strategy"
placeholder="Strategy"
/>
</div>
<div
class="form-group"
style="flex: 1; min-width: 120px"
>
<label>Category</label>
<input
type="text"
id="crm-edit-category"
placeholder="Category"
/>
</div>
</div>
<div class="form-row" style="flex-wrap: wrap">
<div
class="form-group"
style="flex: 1; min-width: 120px"
>
<label>Affiliate</label>
<input
type="text"
id="crm-edit-affiliate"
placeholder="Affiliate"
/>
</div>
<div
class="form-group"
style="flex: 1; min-width: 120px"
>
<label>Number</label>
<input
type="text"
id="crm-edit-number"
placeholder="Phone number"
/>
</div>
<div
class="form-group"
style="flex: 1; min-width: 160px"
>
<label>Date Next Contact</label>
<input type="date" id="crm-edit-date-next" />
</div>
</div>
<div class="form-group" style="margin-top: 10px">
<label>History / Notes</label>
<textarea
id="crm-edit-history"
rows="4"
placeholder="Contact history, notes…"
style="width: 100%; resize: vertical"
></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="crmCloseEdit()">
Cancel
</button>
<button
class="btn btn-primary"
id="crm-save-btn"
onclick="crmSaveEdit()"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
Save Changes
</button>
</div>
</div>
</div>
<!-- ═══ CONFIRM DIALOG ══════════════════════════════════════════════ -->
<div class="modal-backdrop" id="confirm-backdrop">
<div class="modal" id="confirm-modal" style="max-width: 360px">
+118 -73
View File
@@ -8,29 +8,33 @@
}
:root {
--bg: #0f1117;
--bg-card: #1a1d27;
--bg-hover: #22263a;
--bg-input: #12151f;
--border: #2a2f45;
--accent: #4f8ef7;
--accent-dim: #2a4a8a;
--danger: #e05252;
--danger-dim: #5a1f1f;
--success: #3fc97e;
--warning: #f0a04b;
--text: #e4e8f5;
--text-muted: #7a82a0;
--bg: #000810;
--bg-card: #010f20;
--bg-inner: #000d1a;
--bg-hover: #001a30;
--bg-input: #000813;
--border: #0a3060;
--border-hi: rgba(0, 245, 255, 0.45);
--accent: #00f5ff;
--accent-dim: #002535;
--danger: #ff3b3b;
--danger-dim: #3b0000;
--success: #00ff88;
--warning: #ffaa00;
--text: #c8f0ff;
--text-muted: #2e6a80;
--sidebar-w: 230px;
--header-h: 54px;
--radius: 8px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.45);
--shadow:
0 0 0 1px rgba(0, 245, 255, 0.25), 0 0 60px rgba(0, 245, 255, 0.12),
0 28px 72px rgba(0, 0, 0, 0.9);
}
html,
body {
height: 100%;
font-family: "Segoe UI", system-ui, sans-serif;
font-family: "Courier New", Courier, monospace;
background: var(--bg);
color: var(--text);
font-size: 14px;
@@ -63,7 +67,7 @@ body {
border-bottom: 1px solid var(--border);
font-weight: 700;
font-size: 15px;
letter-spacing: 0.3px;
letter-spacing: 1.5px;
color: var(--accent);
flex-shrink: 0;
}
@@ -127,17 +131,23 @@ body {
.topbar {
height: var(--header-h);
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--border-hi);
background: linear-gradient(
90deg,
rgba(0, 245, 255, 0.08),
transparent 60%
);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
padding: 0 22px;
flex-shrink: 0;
gap: 14px;
}
.topbar-title {
font-size: 16px;
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.topbar-actions {
display: flex;
@@ -161,10 +171,10 @@ body {
/* ─── Cards ─────────────────────────────────────────────────── */
.card {
background: var(--bg-card);
background: var(--bg-inner);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
padding: 16px 18px;
margin-bottom: 20px;
}
.card-header {
@@ -176,11 +186,12 @@ body {
flex-wrap: wrap;
}
.card-title {
font-size: 15px;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
gap: 7px;
color: var(--text);
}
/* ─── Buttons ───────────────────────────────────────────────── */
@@ -206,8 +217,19 @@ body {
opacity: 0.7;
}
.btn-primary {
background: var(--accent);
background: transparent;
color: #fff;
border: 1px solid var(--accent);
box-shadow:
0 0 8px rgba(0, 245, 255, 0.35),
inset 0 0 8px rgba(0, 245, 255, 0.04);
}
.btn-primary:hover {
background: rgba(0, 245, 255, 0.08);
box-shadow:
0 0 14px rgba(0, 245, 255, 0.5),
inset 0 0 10px rgba(0, 245, 255, 0.08);
opacity: 1;
}
.btn-danger {
background: var(--danger);
@@ -223,7 +245,7 @@ body {
color: #000;
}
.btn-sm {
padding: 4px 10px;
padding: 5px 10px;
font-size: 12px;
}
.btn:disabled {
@@ -257,7 +279,7 @@ body {
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 8px 10px;
padding: 9px 11px;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
@@ -315,44 +337,44 @@ td:last-child {
font-weight: 600;
}
.badge-a {
background: #1a3a5c;
color: #5fb3f5;
background: #001a2e;
color: #00c8e0;
}
.badge-aaaa {
background: #1a2f4c;
color: #8ac8f8;
background: #001225;
color: #50b8d8;
}
.badge-cname {
background: #2a1f4a;
color: #a888f8;
background: #0e0028;
color: #9060f0;
}
.badge-mx {
background: #1f3a2a;
color: #5fd89a;
background: #001a0e;
color: #00cc60;
}
.badge-txt {
background: #3a2a1a;
color: #f0b060;
background: #1a1000;
color: #cc8800;
}
.badge-ns {
background: #3a1a2a;
color: #f080b0;
background: #200012;
color: #cc5088;
}
.badge-other {
background: #2a2a2a;
color: #9090a0;
background: #080c12;
color: #4a6880;
}
.badge-eu {
background: #1a2f4c;
color: #8ac8f8;
background: #001225;
color: #50b8d8;
}
.badge-us {
background: #1f3a2a;
color: #5fd89a;
background: #001a0e;
color: #00cc60;
}
.badge-region {
background: #2a2a2a;
color: #9090a0;
background: #080c12;
color: #4a6880;
}
.status-dot {
@@ -431,7 +453,7 @@ td:last-child {
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border: 1px solid var(--border-hi);
border-radius: 10px;
width: 100%;
max-width: 500px;
@@ -484,6 +506,7 @@ td:last-child {
/* ─── Confirm dialog ────────────────────────────────────────── */
#confirm-backdrop .modal {
max-width: 380px;
border-radius: 12px;
}
/* ─── Login modal ───────────────────────────────────────────── */
@@ -503,7 +526,7 @@ td:last-child {
}
#login-modal {
background: var(--bg-card);
border: 1px solid var(--border);
border: 1px solid var(--border-hi);
border-radius: 12px;
width: 100%;
max-width: 360px;
@@ -518,13 +541,13 @@ td:last-child {
}
.login-logo h2 {
margin-top: 10px;
font-size: 17px;
font-size: 22px;
font-weight: 700;
color: var(--accent);
color: var(--text);
}
.login-logo p {
margin-top: 4px;
font-size: 12px;
font-size: 13px;
color: var(--text-muted);
}
#login-error {
@@ -587,8 +610,8 @@ td:last-child {
gap: 8px;
}
.mono {
font-family: "Cascadia Code", "Fira Code", monospace;
font-size: 12px;
font-family: "Courier New", Courier, monospace;
font-size: 11.5px;
}
.text-muted {
color: var(--text-muted);
@@ -611,7 +634,7 @@ hr.sep {
.server-tab {
padding: 8px 20px 10px;
cursor: pointer;
border: 1px solid transparent;
border: 1px solid var(--border);
border-bottom: 2px solid transparent;
border-radius: 8px 8px 0 0;
color: var(--text-muted);
@@ -624,6 +647,7 @@ hr.sep {
position: relative;
bottom: -2px;
user-select: none;
background: transparent;
transition:
background 0.12s,
color 0.12s,
@@ -632,14 +656,14 @@ hr.sep {
.server-tab:hover {
background: var(--bg-hover);
color: var(--text);
border-color: var(--border);
border-color: rgba(0, 245, 255, 0.4);
border-bottom-color: var(--bg-hover);
}
.server-tab.active {
background: var(--bg);
border-color: var(--border);
border-bottom-color: var(--bg);
color: var(--text);
background: var(--accent-dim);
border-color: var(--accent);
border-bottom-color: var(--accent-dim);
color: var(--accent);
}
.server-tab-dot {
display: inline-block;
@@ -683,14 +707,14 @@ hr.sep {
.prog-wrap {
background: var(--bg-input);
border-radius: 4px;
height: 6px;
height: 4px;
margin-top: 6px;
overflow: hidden;
}
.prog-bar {
height: 100%;
border-radius: 4px;
transition: width 0.4s ease;
transition: width 0.5s ease;
min-width: 2px;
}
.prog-low {
@@ -735,28 +759,28 @@ hr.sep {
/* ─── Status / risk badges (dashboard) ──────────────────────── */
.badge-green {
background: #1c3a26;
color: #5fd89a;
background: rgba(0, 255, 136, 0.1);
color: var(--success);
}
.badge-yellow {
background: #372900;
color: #f0b060;
background: rgba(255, 170, 0, 0.1);
color: var(--warning);
}
.badge-red {
background: #3a1515;
color: #f07070;
background: rgba(255, 59, 59, 0.12);
color: var(--danger);
}
.badge-low {
background: #1c3a26;
color: #5fd89a;
background: rgba(0, 255, 136, 0.1);
color: var(--success);
}
.badge-medium {
background: #372900;
color: #f0b060;
background: rgba(255, 170, 0, 0.1);
color: var(--warning);
}
.badge-high {
background: #3a1515;
color: #f07070;
background: rgba(255, 59, 59, 0.12);
color: var(--danger);
}
/* ─── Action advice items ─────────────────────────────────── */
@@ -795,3 +819,24 @@ hr.sep {
font-size: 13px;
padding: 3px 0;
}
/* ─── CRM filter inputs ──────────────────────────────────── */
.crm-filter-input {
width: 100%;
box-sizing: border-box;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
padding: 4px 6px;
font-size: 12px;
font-family: inherit;
}
.crm-filter-input:focus {
outline: none;
border-color: var(--border-hi);
}
.crm-search-row th {
padding: 6px 4px;
vertical-align: middle;
}