added support for multiple routes multiple ports
This commit is contained in:
parent
7399cf83f9
commit
7fd2531501
4 changed files with 445 additions and 151 deletions
|
|
@ -149,6 +149,19 @@
|
|||
.domain-input-row { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.domain-input-row input { flex: 1; }
|
||||
|
||||
/* ── Route table ── */
|
||||
.route-row {
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.route-row input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
font-size: .85rem;
|
||||
}
|
||||
.route-row input:first-child { flex: 3; }
|
||||
.route-row button { flex-shrink: 0; }
|
||||
|
||||
/* ── App list ── */
|
||||
.app-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
|
|
@ -294,28 +307,21 @@
|
|||
<label for="name">Name</label>
|
||||
<input id="name" placeholder="whoami" autocomplete="off" />
|
||||
|
||||
<label>Domains</label>
|
||||
<div class="domain-tags" id="domainTags"></div>
|
||||
<div class="domain-input-row">
|
||||
<input id="domainInput" placeholder="whoami.srazka.com" />
|
||||
<button class="btn-sm" id="addDomainBtn">Add</button>
|
||||
<label>Routes <span style="font-weight:400;color:var(--muted);font-size:.78rem;">domain | upstream</span></label>
|
||||
<div id="routeTable"></div>
|
||||
<div class="btn-group" style="margin-top:6px;">
|
||||
<button class="btn-sm" id="addRouteBtn">+ Add Route</button>
|
||||
</div>
|
||||
<p style="font-size:.75rem;color:var(--muted);margin-top:4px;">
|
||||
Supports wildcards: <code>*.example.com</code>
|
||||
Supports wildcards: <code>*.example.com</code>. Upstream: <code>127.0.0.1:PORT</code>
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="port">Host Port</label>
|
||||
<input id="port" type="number" min="1024" max="65535" value="18080" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="auth">Protected</label>
|
||||
<select id="auth">
|
||||
<option value="true" selected>true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="auth">Protected</label>
|
||||
<select id="auth">
|
||||
<option value="true" selected>true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label for="createSource">App Source</label>
|
||||
|
|
@ -382,6 +388,8 @@ const api = {
|
|||
},
|
||||
getApps: () => api.request("/apps"),
|
||||
getApp: (n) => api.request(`/apps/${n}`),
|
||||
getRoutes: (n) => api.request(`/apps/${n}/routes`),
|
||||
setRoutes: (n, r) => api.request(`/apps/${n}/routes`, "POST", { routes: r }),
|
||||
getStatus: (n) => api.request(`/apps/${n}/status`),
|
||||
getLogs: (n, tail=100)=> api.request(`/apps/${n}/logs?tail=${tail}`),
|
||||
getCompose: (n) => api.request(`/apps/${n}/compose`),
|
||||
|
|
@ -427,33 +435,33 @@ function setStatus(msg, isError = false) {
|
|||
statusEl.className = "status-bar" + (isError ? " err" : msg !== "Ready." ? " ok" : "");
|
||||
}
|
||||
|
||||
// ─── Domain tag input ───
|
||||
const domainTags = [];
|
||||
const domainTagsEl = document.getElementById("domainTags");
|
||||
const domainInput = document.getElementById("domainInput");
|
||||
// ─── Route table input ───
|
||||
let createRoutes = [{ domain: "", upstream: "" }];
|
||||
|
||||
function renderDomainTags() {
|
||||
domainTagsEl.innerHTML = "";
|
||||
domainTags.forEach((d, i) => {
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "domain-tag";
|
||||
tag.innerHTML = `${escHtml(d)} <button data-i="${i}">×</button>`;
|
||||
tag.querySelector("button").onclick = () => { domainTags.splice(i, 1); renderDomainTags(); };
|
||||
domainTagsEl.appendChild(tag);
|
||||
function renderRouteTable() {
|
||||
const table = document.getElementById("routeTable");
|
||||
table.innerHTML = "";
|
||||
createRoutes.forEach((r, i) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "route-row";
|
||||
row.innerHTML = `
|
||||
<input class="route-domain" placeholder="whoami.srazka.com" value="${escHtml(r.domain)}" />
|
||||
<span style="color:var(--muted);font-weight:600;">→</span>
|
||||
<input class="route-upstream" placeholder="127.0.0.1:18080" value="${escHtml(r.upstream)}" />
|
||||
${createRoutes.length > 1 ? `<button class="btn-sm btn-danger" data-ridx="${i}">×</button>` : ""}
|
||||
`;
|
||||
row.querySelector(".route-domain").oninput = (e) => { createRoutes[i].domain = e.target.value; };
|
||||
row.querySelector(".route-upstream").oninput = (e) => { createRoutes[i].upstream = e.target.value; };
|
||||
const delBtn = row.querySelector("[data-ridx]");
|
||||
if (delBtn) delBtn.onclick = () => { createRoutes.splice(i, 1); renderRouteTable(); };
|
||||
table.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("addDomainBtn").onclick = () => {
|
||||
const v = domainInput.value.trim();
|
||||
if (v && !domainTags.includes(v)) {
|
||||
domainTags.push(v);
|
||||
domainInput.value = "";
|
||||
renderDomainTags();
|
||||
}
|
||||
document.getElementById("addRouteBtn").onclick = () => {
|
||||
createRoutes.push({ domain: "", upstream: "" });
|
||||
renderRouteTable();
|
||||
};
|
||||
domainInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); document.getElementById("addDomainBtn").click(); }
|
||||
});
|
||||
|
||||
const sourceSelect = document.getElementById("createSource");
|
||||
const sourceRaw = document.getElementById("sourceRaw");
|
||||
|
|
@ -466,10 +474,7 @@ sourceSelect.addEventListener("change", () => {
|
|||
|
||||
document.getElementById("createBtn").onclick = async () => {
|
||||
const name = document.getElementById("name").value.trim();
|
||||
const port = Number(document.getElementById("port").value);
|
||||
const auth = document.getElementById("auth").value === "true";
|
||||
const domains = [...domainTags];
|
||||
|
||||
const source_type = sourceSelect.value;
|
||||
const compose_content = document.getElementById("createCompose").value;
|
||||
const github_url = document.getElementById("createGithubUrl").value.trim();
|
||||
|
|
@ -477,22 +482,32 @@ document.getElementById("createBtn").onclick = async () => {
|
|||
const github_pat = document.getElementById("createGithubPat").value.trim();
|
||||
|
||||
if (!name) { setStatus("Name is required.", true); return; }
|
||||
if (!domains.length) { setStatus("At least one domain is required.", true); return; }
|
||||
if (!port || port < 1024 || port > 65535) { setStatus("Port must be 1024-65535.", true); return; }
|
||||
|
||||
// Validate routes
|
||||
const validRoutes = createRoutes.filter(r => r.domain.trim() && r.upstream.trim());
|
||||
if (!validRoutes.length) { setStatus("At least one route with domain and upstream is required.", true); return; }
|
||||
for (const r of validRoutes) {
|
||||
const port = r.upstream.split(":").pop();
|
||||
const pnum = Number(port);
|
||||
if (!port || isNaN(pnum) || pnum < 1024 || pnum > 65535) {
|
||||
setStatus(`Upstream port must be 1024-65535 in "${r.upstream}"`, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (source_type === "raw" && !compose_content.trim()) { setStatus("Compose YAML is required for raw source.", true); return; }
|
||||
if (source_type === "github" && !github_url) { setStatus("Repository URL is required for GitHub source.", true); return; }
|
||||
|
||||
try {
|
||||
setStatus(`Creating ${name}...`);
|
||||
await api.init({ name, domain: domains.join(","), port, auth, source_type, compose_content, github_url, github_branch, github_pat });
|
||||
await api.init({ name, routes: validRoutes.map(r => ({ domain: r.domain.trim(), upstream: r.upstream.trim() })), auth, source_type, compose_content, github_url, github_branch, github_pat });
|
||||
setStatus(`Created ${name}.`);
|
||||
document.getElementById("name").value = "";
|
||||
document.getElementById("createCompose").value = "";
|
||||
document.getElementById("createGithubUrl").value = "";
|
||||
document.getElementById("createGithubPat").value = "";
|
||||
domainTags.length = 0;
|
||||
renderDomainTags();
|
||||
createRoutes = [{ domain: "", upstream: "" }];
|
||||
renderRouteTable();
|
||||
await loadApps();
|
||||
} catch (err) {
|
||||
setStatus(`Create failed: ${err.message}`, true);
|
||||
|
|
@ -509,17 +524,23 @@ function escHtml(s) {
|
|||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function formatDomains(domainsStr) {
|
||||
return domainsStr.split(",").map(d => d.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// ─── App card rendering ───
|
||||
function renderAppCard(app) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "app-card fade-in";
|
||||
card.dataset.name = app.name;
|
||||
|
||||
const domains = formatDomains(app.domains || app.domain);
|
||||
// Parse first_route for display
|
||||
let firstDomain = app.first_route || app.domain || "";
|
||||
let firstUpstream = app.upstream || "";
|
||||
if (app.first_route && app.first_route.includes("|")) {
|
||||
const parts = app.first_route.split("|");
|
||||
firstDomain = parts[0];
|
||||
firstUpstream = parts[1];
|
||||
}
|
||||
const routeCount = parseInt(app.route_count || "1", 10);
|
||||
const extraLabel = routeCount > 1 ? ` +${routeCount - 1} more route${routeCount > 2 ? "s" : ""}` : "";
|
||||
|
||||
const isExpanded = expandedApp === app.name;
|
||||
|
||||
card.innerHTML = `
|
||||
|
|
@ -528,7 +549,7 @@ function renderAppCard(app) {
|
|||
<span class="app-status-dot dot-unknown" id="dot-${app.name}"></span>
|
||||
<div class="app-info">
|
||||
<div class="app-name">${escHtml(app.name)}</div>
|
||||
<div class="app-meta">${domains.map(d => escHtml(d)).join(", ")} → ${escHtml(app.upstream)} ${app.auth === "true" ? "🔒" : ""}</div>
|
||||
<div class="app-meta">${escHtml(firstDomain)} → ${escHtml(firstUpstream)}${escHtml(extraLabel)} ${app.auth === "true" ? "🔒" : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-actions">
|
||||
|
|
@ -591,9 +612,17 @@ function renderAppCard(app) {
|
|||
</div>
|
||||
<div class="tab-panel" id="tab-routing-${app.name}">
|
||||
<div class="btn-group" style="margin-bottom:10px;">
|
||||
<button class="btn-sm btn-primary" data-action="fetch-routing" data-app="${app.name}">Load App Variables</button>
|
||||
<button class="btn-sm btn-primary" data-action="fetch-routing" data-app="${app.name}">Edit Routes</button>
|
||||
</div>
|
||||
<div id="routing-editor-${app.name}">
|
||||
<div id="route-edit-table-${app.name}"></div>
|
||||
<div class="btn-group" style="margin-top:8px;">
|
||||
<button class="btn-sm" data-action="add-route-edit" data-app="${app.name}">+ Add Route</button>
|
||||
<button class="btn-sm btn-primary" data-action="save-routes" data-app="${app.name}">Save Routes</button>
|
||||
</div>
|
||||
<p style="font-size:.75rem;color:var(--muted);margin-top:6px;">Saving updates the Caddy route file (hot reload). Edit the compose file separately if new ports need exposing.</p>
|
||||
<div class="backup-list" style="margin-top:8px;" id="routing-info-${app.name}"></div>
|
||||
</div>
|
||||
<div class="log-output" id="routing-${app.name}">Click "Load App Variables" to view routing details & directories.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -658,6 +687,7 @@ function attachCardListeners() {
|
|||
if (tabName === "logs") loadLogs(app);
|
||||
if (tabName === "backups") loadBackups(app);
|
||||
if (tabName === "volumes") initVolumesTab(app);
|
||||
if (tabName === "routing") loadRouteEditor(app);
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -781,23 +811,13 @@ async function handleAction(action, name, btnElement) {
|
|||
uploadInput.click();
|
||||
break;
|
||||
case "fetch-routing":
|
||||
setStatus(`Fetching routing details for ${name}...`);
|
||||
const appRes = await api.getApp(name);
|
||||
if (appRes.app) {
|
||||
const info = appRes.app;
|
||||
document.getElementById(`routing-${name}`).innerHTML = `
|
||||
<b>Primary Domain:</b> ${escHtml(info.APP_DOMAIN || "-")}
|
||||
<b>All Domains:</b> ${escHtml(info.APP_DOMAINS || "-")}
|
||||
<b>Upstream Target:</b> ${escHtml(info.APP_UPSTREAM || "-")}
|
||||
<b>Protected:</b> ${escHtml(info.APP_AUTH_PROTECTED || "-")}
|
||||
<b>Port:</b> ${escHtml(info.APP_PORT || "-")}
|
||||
|
||||
<b>Volumes Directory:</b> ${escHtml(info.APP_VOLUME_DIR || "-")}
|
||||
<b>Stack Directory:</b> ${escHtml(info.APP_STACK_DIR || "-")}
|
||||
<b>Caddy Route Block:</b> ${escHtml(info.APP_ROUTE_FILE || "-")}
|
||||
`.trim();
|
||||
}
|
||||
setStatus(`Loaded routing for ${name}.`);
|
||||
await loadRouteEditor(name);
|
||||
break;
|
||||
case "save-routes":
|
||||
await saveRouteEditor(name);
|
||||
break;
|
||||
case "add-route-edit":
|
||||
await addRouteEditRow(name);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -885,6 +905,73 @@ async function loadLogs(name) {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Route editor (routing tab) ───
|
||||
let routeEditState = {}; // { appName: [{ domain, upstream }, ...] }
|
||||
|
||||
async function loadRouteEditor(name) {
|
||||
try {
|
||||
const data = await api.getRoutes(name);
|
||||
routeEditState[name] = data.routes || [];
|
||||
renderRouteEditor(name);
|
||||
setStatus(`Loaded ${data.routes.length} route(s) for ${name}.`);
|
||||
} catch (err) {
|
||||
const info = document.getElementById(`routing-info-${name}`);
|
||||
if (info) info.innerHTML = `<span style="color:var(--warn);">Failed: ${err.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderRouteEditor(name) {
|
||||
const table = document.getElementById(`route-edit-table-${name}`);
|
||||
const routes = routeEditState[name] || [];
|
||||
if (!table) return;
|
||||
table.innerHTML = "";
|
||||
routes.forEach((r, i) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "route-row";
|
||||
row.innerHTML = `
|
||||
<input class="route-domain" placeholder="whoami.srazka.com" value="${escHtml(r.domain)}" />
|
||||
<span style="color:var(--muted);font-weight:600;">→</span>
|
||||
<input class="route-upstream" placeholder="127.0.0.1:18080" value="${escHtml(r.upstream)}" />
|
||||
<button class="btn-sm btn-danger" data-ridx="${i}">×</button>
|
||||
`;
|
||||
row.querySelector(".route-domain").oninput = (e) => { routeEditState[name][i].domain = e.target.value; };
|
||||
row.querySelector(".route-upstream").oninput = (e) => { routeEditState[name][i].upstream = e.target.value; };
|
||||
row.querySelector("[data-ridx]").onclick = () => {
|
||||
routeEditState[name].splice(i, 1);
|
||||
renderRouteEditor(name);
|
||||
};
|
||||
table.appendChild(row);
|
||||
});
|
||||
if (!routes.length) {
|
||||
table.innerHTML = '<div style="color:var(--muted);font-size:.85rem;padding:8px 0;">No routes defined.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function addRouteEditRow(name) {
|
||||
if (!routeEditState[name]) routeEditState[name] = [];
|
||||
routeEditState[name].push({ domain: "", upstream: "" });
|
||||
renderRouteEditor(name);
|
||||
}
|
||||
|
||||
async function saveRouteEditor(name) {
|
||||
const routes = routeEditState[name] || [];
|
||||
const valid = routes.filter(r => r.domain.trim() && r.upstream.trim());
|
||||
if (!valid.length) {
|
||||
setStatus("At least one valid route is required.", true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setStatus(`Saving routes for ${name}...`);
|
||||
const cleanRoutes = valid.map(r => ({ domain: r.domain.trim(), upstream: r.upstream.trim() }));
|
||||
await api.setRoutes(name, cleanRoutes);
|
||||
routeEditState[name] = cleanRoutes;
|
||||
renderRouteEditor(name);
|
||||
setStatus(`Routes saved for ${name}. Caddy reloaded.`);
|
||||
} catch (err) {
|
||||
setStatus(`Save routes failed: ${err.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
window.api = api; // Expose for inline html onclicks
|
||||
|
||||
async function initVolumesTab(name) {
|
||||
|
|
@ -1078,6 +1165,7 @@ async function loadBackups(name) {
|
|||
}
|
||||
|
||||
// ─── Init ───
|
||||
renderRouteTable();
|
||||
loadApps();
|
||||
|
||||
// Auto-refresh every 15s
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue