added support for multiple routes multiple ports

This commit is contained in:
Jakub Dorfman 2026-05-20 00:21:25 +02:00
parent 7399cf83f9
commit 7fd2531501
4 changed files with 445 additions and 151 deletions

View file

@ -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}">&times;</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}">&times;</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}">&times;</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