CH-J Toolsуправление серверами через SSH
Меню
Публикуемый источник

CH-J Tools

Просмотр каталогов и файлов для конкретного выпуска приложения.

Загрузка источника ZIP
CH-J / Server Manager 1704 файлы
0.1.2-beta Добавлено: · Построено:
sites.js Добавлено: Дата нерегистрации 62,3 KB · 1031 Линии
Код Загрузить файл
1"use strict";
3// Graphical tabs of the NGINX Manager. Shares api, t, elements and the session helpers with index.js.
4// All text from the server is inserted as plain text.
5const sitesState = { inventory: null, certificates: null, selected: null, plan: null, pendingEdit: null, backends: null, history: null, logTimer: 0, route: null, routePlan: null, lastChange: [], seenDiffs: new Set() };
6const byId = (id) => document.getElementById(id);
7const TYPE_LABELS = { static: "Statický web", spa: "SPA", php: "PHP", proxy: "Reverse proxy", redirect: "Přesměrování", other: "Jiná konfigurace", stream: "Stream" };
8const WIZARD_FIELDS = {
9 static: ["wizardRootField", "wizardIndexField", "wizardCreateRootField"],
10 spa: ["wizardRootField", "wizardIndexField", "wizardCreateRootField"],
11 php: ["wizardRootField", "wizardIndexField", "wizardCreateRootField", "wizardPhpField"],
12 proxy: ["wizardBackendField", "wizardConnectTimeoutField", "wizardReadTimeoutField"],
13 websocket: ["wizardBackendField", "wizardConnectTimeoutField", "wizardReadTimeoutField"],
14 loadbalance: ["wizardBackendsField", "wizardMethodField", "wizardConnectTimeoutField", "wizardReadTimeoutField"],
15 redirect: ["wizardRedirectTargetField", "wizardRedirectCodeField"],
16 custom: ["wizardTemplateField", "wizardRootField", "wizardCreateRootField", "wizardBackendField", "wizardPhpField"]
17};
19function node(tag, className, text) {
20 const element = document.createElement(tag);
21 if (className) element.className = className;
22 if (text !== undefined) element.textContent = text;
23 return element;
26function sudoFields() { return { sudoPassword: sudoPassword(), rememberSudo: rememberSudo() }; }
27function errorText(error) { return String(error?.message || error).replace(/^Error invoking remote method '[^']*': Error: /, ""); }
28function needsPassword(error) { return /requires the sudo password/i.test(errorText(error)); }
29function siteName(site) { return site.serverNames?.find((name) => name !== "_") || site.path; }
30function httpSites() { return (sitesState.inventory?.sites || []).filter((site) => site.kind === "http"); }
31// Blocks with a domain of their own: the plain HTTP and www blocks are shown with the site they lead to.
32function listedSites() { return httpSites().filter((site) => site.role === "site" || site.role === "redirect"); }
33function rolesOf(...roles) { return (sitesState.inventory?.sites || []).filter((site) => roles.includes(site.role)); }
34// One row per domain, with what it does over HTTP and over TLS.
35function routeRows(routes) {
36 const rows = new Map();
37 for (const route of routes) {
38 if (!rows.has(route.host)) rows.set(route.host, { host: route.host, isDefault: route.isDefault, http: "", tls: "", editable: true, files: [] });
39 const row = rows.get(route.host);
40 row[route.protocol] = route.backend;
41 if (route.kind === "regex" || route.kind === "default") row.editable = false;
42 if (route.file && !row.files.includes(route.file)) row.files.push(route.file);
43 }
44 return [...rows.values()].sort((a, b) => Number(a.isDefault) - Number(b.isDefault));
46function serverKey() { return selectedSession()?.host || ""; }
47function readStored(key, fallback) { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } }
48function writeStored(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch { return false; } }
49function sameSite(a, b) { return Boolean(a && b) && a.path === b.path && a.ordinal === b.ordinal; }
51// One guarded operation: busy state, late answers after a server switch, password field and status.
52async function siteOperation(startMessage, work) {
53 const requestGeneration = generation;
54 setBusy(true); setStatus(startMessage);
55 try { return await work(requestGeneration); }
56 catch (error) {
57 if (requestGeneration !== generation || error?.message === "CHJ_SESSION_CHANGED") return undefined;
58 setOutput(errorText(error), true); setStatus(errorText(error), true);
59 return undefined;
60 } finally { if (requestGeneration === generation) { elements.sudoPassword.value = ""; setBusy(false); } }
63async function loadSites(automatic = false) {
64 const id = elements.sessionSelect.value;
65 if (!id) return;
66 const requestGeneration = generation;
67 try {
68 const inventory = await sessionResult(api.nginx.sites(id, sudoFields()), requestGeneration);
69 sitesState.inventory = inventory;
70 if (sitesState.selected) sitesState.selected = inventory.sites.find((site) => sameSite(site, sitesState.selected)) || null;
71 renderSites();
72 if (!automatic) setStatus(t("Načteno webů: {count}.", { count: listedSites().length }));
73 } catch (error) {
74 if (requestGeneration !== generation || error?.message === "CHJ_SESSION_CHANGED") return;
75 showSitesNotice(needsPassword(error) ? t("Přehled webů čte nginx -T, které potřebuje oprávnění root. Zadejte heslo pro sudo a klikněte na Načíst weby.") : errorText(error));
76 }
79function showSitesNotice(text) {
80 const notice = byId("sitesNotice");
81 notice.textContent = text || "";
82 notice.hidden = !text;
85function badge(text, tone = "") { return node("span", `badge${tone ? ` ${tone}` : ""}`, text); }
87function siteTarget(site) {
88 if (site.type === "redirect") return `→ ${site.redirect?.target || ""}`;
89 const parts = [site.root, site.fastcgiPass?.[0], ...(site.proxyPass || []).map((target) => `→ ${target}`)].filter(Boolean);
90 return parts.join(" · ") || site.path;
93function siteRow(site, onClick) {
94 const row = node("button", `site-row${sameSite(site, sitesState.selected) ? " active" : ""}`);
95 row.type = "button";
96 const main = node("span", "site-main");
97 const names = site.serverNames?.filter((name) => name !== "_") || [];
98 const title = names.length > 3 ? `${names.slice(0, 3).join(", ")} ${t("+{count} dalších", { count: names.length - 3 })}` : names.join(", ");
99 main.append(node("strong", "", title || t("Výchozí server na portu {ports}", { ports: (site.ports || []).join(", ") || "—" })), node("small", "", `${names.length ? t(TYPE_LABELS[site.type] || site.type) : site.path} ${names.length || site.proxyPass?.length || site.root ? siteTarget(site) : ""}`));
100 const badges = node("span", "badges");
101 if (site.aliases?.length) badges.append(badge(t("+{count} přesměrované domény", { count: site.aliases.length })));
102 if (site.redirectsHttp) badges.append(badge("HTTP → HTTPS"));
103 badges.append(site.https ? badge("HTTPS", "good") : badge("HTTP", "warn"), site.active ? badge(t("Aktivní"), "good") : badge(t("Neaktivní")));
104 row.append(main, badges);
105 row.addEventListener("click", onClick);
106 return row;
109function fill(container, items, emptyText) {
110 if (items.length) container.replaceChildren(...items);
111 else container.replaceChildren(node("p", "empty", emptyText));
114function plainRow(title, detail, badges = []) {
115 const row = node("div", "site-row");
116 const main = node("span", "site-main");
117 main.append(node("strong", "", title), node("small", "", detail));
118 const holder = node("span", "badges");
119 holder.append(...badges);
120 row.append(main, holder);
121 return row;
124function warningCount() {
125 const inventory = sitesState.inventory;
126 if (!inventory) return 0;
127 const expiring = (sitesState.certificates?.certificates || []).filter((item) => item.state !== "ok" || item.daysLeft < 21).length;
128 // A different but working layout is not a problem; a symlink without a target is.
129 const broken = (inventory.proposals || []).filter((item) => item.kind === "broken-link").length;
130 return inventory.conflicts.length + inventory.parseErrors.length + broken + (inventory.dumpOk ? 0 : 1) + expiring;
133function renderSites() {
134 const inventory = sitesState.inventory;
135 const sites = listedSites();
136 const known = Boolean(inventory);
137 const rows = routeRows(inventory?.routes || []);
138 const named = rows.filter((row) => !row.isDefault);
139 byId("statSites").textContent = known ? String(sites.length) : "—";
140 byId("statHttps").textContent = known ? String(sites.filter((site) => site.https).length) : "—";
141 byId("statProxy").textContent = known ? String(named.length + sites.filter((site) => site.type === "proxy").length) : "—";
142 byId("statWarnings").textContent = known ? String(warningCount()) : "—";
143 byId("statWarnings").classList.toggle("warn", warningCount() > 0);
144 if (inventory && !inventory.dumpOk) showSitesNotice(t("Konfigurace NGINX neprošla testem, aktivní weby proto nejde načíst: {output}", { output: inventory.testOutput }));
145 else if (inventory && !sites.length && named.length) showSitesNotice(t("Tento server nemá vlastní weby. Směruje {count} domén na backendy, najdete je v záložce Reverse Proxy.", { count: named.length }));
146 else if (inventory) showSitesNotice("");
147 const open = (site) => { switchTab("sites"); openDetail(site); };
148 fill(byId("siteList"), sites.map((site) => siteRow(site, () => openDetail(site))), known ? t("Na serveru zatím není žádný web.") : t("Weby zatím nejsou načtené."));
149 const catchAll = rolesOf("catch-all");
150 byId("catchAllBlock").hidden = !catchAll.length;
151 fill(byId("catchAllList"), catchAll.map((site) => siteRow(site, () => openDetail(site))), "");
152 fill(byId("proxyList"), [...rolesOf("gateway"), ...sites.filter((site) => site.type === "proxy" || site.proxyPass?.length)].map((site) => siteRow(site, () => open(site))), known ? t("Žádný web nepředává požadavky na backend.") : t("Weby zatím nejsou načtené."));
153 byId("routesBlock").hidden = !rows.length;
154 renderRoutes(rows);
155 const stream = rolesOf("stream");
156 byId("streamBlock").hidden = !stream.length;
157 fill(byId("streamList"), stream.map((site) => plainRow((site.listen || []).join(" · ") || t("(bez portu)"), [site.proxyPass?.join(", "), site.path].filter(Boolean).join(" · "), [badge(site.proxyPass?.some((target) => target.includes("$")) ? t("Podle domény (SNI)") : t("Pevný cíl"))])), "");
158 fill(byId("upstreamList"), (inventory?.upstreams || []).map((pool) => plainRow(pool.name, `${pool.servers.join(", ")} · ${pool.path}`, [badge(pool.method)])), t("V konfiguraci není žádná skupina upstream."));
159 byId("wizardPhpList").replaceChildren(...(inventory?.phpSockets || []).map((socket) => { const option = node("option"); option.value = socket; return option; }));
160 if (inventory?.phpSockets?.length && !byId("wizardPhp").value) byId("wizardPhp").value = inventory.phpSockets[0];
161 renderProposals(); renderAcmeSites(); renderLogFiles(); renderCertificates(); renderDiagnostics(); renderDetail();
162 if (typeof renderConfigList === "function" && elements) renderConfigList();
165function renderRoutes(rows = routeRows(sitesState.inventory?.routes || [])) {
166 const query = byId("routeFilter").value.trim().toLowerCase();
167 const visible = rows.filter((row) => !query || `${row.host} ${row.http} ${row.tls}`.toLowerCase().includes(query));
168 const action = (label, handler) => { const button = node("button", "mini", label); button.type = "button"; button.addEventListener("click", handler); return button; };
169 fill(byId("routeList"), visible.map((row) => {
170 const detail = [row.http && `HTTP → ${row.http}`, row.tls && `TLS → ${row.tls}`].filter(Boolean).join(" · ");
171 const element = plainRow(row.isDefault ? t("Ostatní domény") : row.host, detail || t("Odmítnuto"), row.isDefault ? [badge(t("Výchozí"))] : []);
172 const holder = element.querySelector(".badges");
173 if (!row.isDefault) holder.append(action(t("Cesta"), () => { switchTab("diag"); byId("diagDomain").value = row.host.replace(/^\.|^\*\./, ""); void diagnoseDomain(); }));
174 if (row.editable) holder.append(action(t("Upravit"), () => openRouteEditor(row)), action(t("Odebrat"), () => void removeRoute(row)));
175 else if (!row.isDefault) holder.append(badge(t("Složité pravidlo, jen editor"), "warn"));
176 if (!row.editable) holder.append(action(t("Otevřít soubor"), () => { switchTab("config"); void readConfig(row.files[0], "file"); }));
177 return element;
178 }), t("Filtru neodpovídá žádná trasa."));
179 const files = [...new Set((sitesState.inventory?.routes || []).map((route) => route.file).filter(Boolean))];
180 byId("routeFiles").replaceChildren(...files.map((path) => {
181 const button = node("button", "mini", path.split("/").pop());
182 button.type = "button"; button.title = path;
183 button.addEventListener("click", () => { switchTab("config"); void readConfig(path, "file"); });
184 return button;
185 }));
188// --- route editor ---------------------------------------------------------------------------------
190function openRouteEditor(row) {
191 sitesState.route = row;
192 sitesState.routePlan = null;
193 byId("routeDiff").hidden = true;
194 byId("routeEditor").hidden = false;
195 byId("routeEditorTitle").textContent = row ? row.host : t("Nová doména");
196 byId("routeHost").value = row?.host || "";
197 byId("routeHost").disabled = Boolean(row);
198 byId("routeHttp").value = row?.http || "";
199 byId("routeTls").value = row?.tls || "";
200 byId("routeHttpNow").textContent = row?.http ? t("Nyní: {value}", { value: row.http }) : "";
201 byId("routeTlsNow").textContent = row?.tls ? t("Nyní: {value}", { value: row.tls }) : "";
202 byId("routeEditor").scrollIntoView({ block: "start" });
205function routeChange() {
206 const row = sitesState.route;
207 const change = { host: byId("routeHost").value.trim().toLowerCase(), mode: row ? "update" : "add" };
208 const http = byId("routeHttp").value.trim(), tls = byId("routeTls").value.trim();
209 if (row) { if (http !== row.http) change.http = http || null; if (tls !== row.tls) change.tls = tls || null; }
210 else { if (http) change.http = http; if (tls) change.tls = tls; }
211 return change;
214const ROUTE_WARNINGS = {
215 "one-protocol": () => t("Doména bude mít trasu jen pro jeden protokol; druhý provoz skončí na výchozím pravidle."),
216 "overrides-rule": (item) => t("Přesný název přebije obecnější pravidlo {rule}.", { rule: item.rule }),
217 "backend-closed": (item) => t("Backend {backend} teď ze serveru neodpovídá. Trasu lze uložit, ale do jeho spuštění nebude fungovat.", { backend: item.backend })
218};
220async function planRoute(change) {
221 await siteOperation(t("Připravuji náhled změn…"), async (requestGeneration) => {
222 const plan = await sessionResult(api.nginx.planRoute(elements.sessionSelect.value, { change, ...sudoFields() }), requestGeneration);
223 if (!plan.files.length) { sitesState.routePlan = null; byId("routeDiff").hidden = true; setStatus(t("Nebyla provedena žádná změna.")); return; }
224 sitesState.routePlan = { change, plan };
225 const warnings = byId("routeWarnings");
226 warnings.textContent = plan.warnings.map((item) => ROUTE_WARNINGS[item.code]?.(item) || item.code).join(" ");
227 warnings.hidden = !plan.warnings.length;
228 byId("routeDiffFiles").replaceChildren(...plan.files.flatMap((file) => {
229 const output = node("pre", "code diff");
230 renderDiff(output, file.before, file.after);
231 return [node("p", "muted", file.path), output];
232 }));
233 byId("routeDiff").hidden = false;
234 byId("routeDiff").scrollIntoView({ block: "nearest" });
235 setStatus(t("Náhled je připravený, na serveru se zatím nic nezměnilo."));
236 });
239async function removeRoute(row) {
240 openRouteEditor(row);
241 byId("routeHttp").value = ""; byId("routeTls").value = "";
242 await planRoute({ host: row.host, mode: "remove", http: null, tls: null });
245async function applyRoute() {
246 const pending = sitesState.routePlan;
247 if (!pending) return;
248 const list = pending.plan.files.map((file) => file.path).join(", ");
249 if (!window.confirm(`${api.sessions.displayName(selectedSession())}\n\n${t("Uložit změnu trasy {host} do {files}, zálohovat a spustit nginx -t? Při chybě se vrátí všechny soubory.", { host: pending.change.host, files: list })}`)) return;
250 await siteOperation(t("Ukládám zálohu a ověřuji novou konfiguraci…"), async (requestGeneration) => {
251 const expected = Object.fromEntries(pending.plan.files.map((file) => [file.path, file.sha256]));
252 const result = await sessionResult(api.nginx.applyRoute(elements.sessionSelect.value, { change: pending.change, expected, confirm: true, ...sudoFields() }), requestGeneration);
253 rememberChange(result.saved.map((item) => ({ path: item.path, backup: item.backupPath, sha256: item.sha256 })));
254 setOutput(`${result.testOutput}\n\n${result.saved.map((item) => t("Záloha: {path}", { path: item.backupPath })).join("\n")}`);
255 sitesState.routePlan = null;
256 byId("routeEditor").hidden = true;
257 await sessionResult(inspect(), requestGeneration);
258 await loadSites(true);
259 setStatus(t("Trasa byla uložena a nginx -t uspěl. Projeví se po plynulém načtení konfigurace."));
260 });
263// --- undoing the last change ----------------------------------------------------------------------
265function rememberChange(items) {
266 sitesState.lastChange = items;
267 byId("undoButton").hidden = !items.length;
270async function undoLastChange() {
271 const items = sitesState.lastChange;
272 if (!items.length) return;
273 if (!window.confirm(`${api.sessions.displayName(selectedSession())}\n\n${t("Vrátit {files} do stavu před poslední změnou a spustit nginx -t?", { files: items.map((item) => item.path).join(", ") })}`)) return;
274 await siteOperation(t("Obnovuji zálohu a ověřuji konfiguraci…"), async (requestGeneration) => {
275 for (const item of items) await sessionResult(api.nginx.restoreBackup(elements.sessionSelect.value, { path: item.path, backup: item.backup, expectedSha256: item.sha256, confirm: true, ...sudoFields() }), requestGeneration);
276 rememberChange([]);
277 await sessionResult(inspect(), requestGeneration);
278 await loadSites(true);
279 setStatus(t("Změna byla vrácena a nginx -t uspěl. Projeví se po plynulém načtení konfigurace."));
280 });
283// --- path of a domain -----------------------------------------------------------------------------
285function pathStep(label, value, state) {
286 const row = node("div", "path-step");
287 const shown = node("code", "", value || "—");
288 shown.title = value || "";
289 row.append(node("span", "", label), shown);
290 row.append(state || node("span"));
291 return row;
294function stateBadge(state) {
295 return state === "open" ? badge(t("TCP dostupné"), "good") : state === "closed" ? badge(t("TCP nedostupné"), "bad") : badge(t("Nezjištěno"), "warn");
298const RULE_KINDS = { exact: "přesný název", wildcard: "zástupný název", regex: "regulární výraz", default: "výchozí pravidlo" };
300async function diagnoseDomain() {
301 const host = byId("diagDomain").value.trim().toLowerCase();
302 if (!host) { setStatus(t("Zadejte doménu."), true); return; }
303 await siteOperation(t("Zjišťuji cestu domény…"), async (requestGeneration) => {
304 const result = await sessionResult(api.nginx.diagnoseDomain(elements.sessionSelect.value, { host, ...sudoFields() }), requestGeneration);
305 const cards = result.rules.map((rule) => {
306 const card = node("div", "path-card");
307 card.append(node("h3", "", `${rule.protocol === "tls" ? t("HTTPS podle SNI") : "HTTP"} · ${t("port {port}", { port: (rule.port || []).join(", ") || "—" })}`));
308 card.append(pathStep(t("Naslouchá"), rule.listenFile), pathStep(t("Mapa"), `${rule.mapFile} (${rule.variable})`), pathStep(t("Pravidlo"), `${rule.ruleKey} · ${t(RULE_KINDS[rule.ruleKind] || rule.ruleKind)} · ${rule.ruleFile}`));
309 if (!rule.members.length) card.append(pathStep(t("Backend"), t("Odmítnuto"), badge(t("Bez backendu"), "warn")));
310 for (const member of rule.members) card.append(pathStep(rule.backend === member ? t("Backend") : `${t("Backend")} ${rule.backend}`, member, stateBadge(result.states[member])));
311 return card;
312 });
313 if (result.site) {
314 const site = result.site;
315 const card = node("div", "path-card");
316 card.append(node("h3", "", `${t("Web na tomto serveru")} · ${t("port {port}", { port: (site.ports || []).join(", ") || "—" })}`));
317 card.append(pathStep(t("Soubor"), `${site.path} · #${site.ordinal}`), pathStep(t("Typ"), t(TYPE_LABELS[site.type] || site.type)));
318 if (site.root) card.append(pathStep(t("Kořen"), site.root));
319 if (site.certificate) card.append(pathStep("TLS", site.certificate));
320 for (const target of result.targets) card.append(pathStep(t("Backend"), target, stateBadge(result.states[target])));
321 cards.push(card);
322 }
323 fill(byId("diagDomainResult"), cards, t("Tato konfigurace doménu nezná; odpoví na ni výchozí server, pokud nějaký je."));
324 setStatus(t("Cesta domény {host} byla zjištěna.", { host: result.host }));
325 });
328// Everything that can be opened: files on disk, and what NGINX loaded, also from outside its directory.
329function configEntries() {
330 const included = new Map((sitesState.inventory?.includedFiles || []).map((file) => [file.path, file]));
331 const known = sitesState.inventory !== null;
332 const entries = (inspection?.configs || []).map((item) => ({ ...item, loaded: included.has(item.path) || included.has(item.editablePath), known }));
333 for (const file of included.values()) {
334 if (!file.managed && !entries.some((entry) => entry.path === file.path)) entries.push({ path: file.path, kind: "external", writable: false, loaded: true, known });
335 }
336 return entries.sort((a, b) => Number(b.loaded) - Number(a.loaded) || a.path.localeCompare(b.path));
339function externalConfigText(path) { return (sitesState.inventory?.includedFiles || []).find((file) => file.path === path)?.text ?? null; }
341// --- wizard ---------------------------------------------------------------------------------------
343function syncWizard() {
344 const type = byId("wizardType").value;
345 const all = new Set(Object.values(WIZARD_FIELDS).flat());
346 for (const id of all) byId(id).hidden = !WIZARD_FIELDS[type].includes(id);
347 byId("wizardTlsField").hidden = type === "custom";
348 for (const id of ["wizardIpv6Field", "wizardHeadersField", "wizardRateLimitField"]) byId(id).hidden = type === "custom";
349 const tls = type === "custom" ? "none" : byId("wizardTls").value;
350 for (const id of ["wizardCertificateField", "wizardKeyField", "wizardRedirectHttpField", "wizardHttp2Field", "wizardHttp3Field", "wizardModernTlsField"]) byId(id).hidden = tls !== "existing";
351 const http3 = Boolean(inspection?.capabilities?.http3);
352 byId("wizardHttp3").disabled = !http3;
353 if (!http3) byId("wizardHttp3").checked = false;
354 const root = byId("wizardRoot");
355 const domain = byId("wizardDomain").value.trim().toLowerCase().replace(/^\*\./, "");
356 if (!root.dataset.edited) root.value = domain ? `/var/www/${domain}` : "";
357 const index = byId("wizardIndex");
358 if (!index.dataset.edited) index.value = type === "php" ? "index.php index.html" : "index.html";
359 invalidatePlan();
362function invalidatePlan() {
363 sitesState.plan = null;
364 byId("wizardDeploy").disabled = true;
365 byId("wizardResult").hidden = true;
366 byId("wizardToTemplate").hidden = true;
369const TEMPLATES_KEY = "chj.nginx.templates";
370function storedTemplates() { const value = readStored(TEMPLATES_KEY, {}); return value && typeof value === "object" ? value : {}; }
372function renderTemplates() {
373 const select = byId("wizardTemplateSelect");
374 const previous = select.value;
375 const names = Object.keys(storedTemplates()).sort();
376 const none = node("option", "", t("Nová předloha")); none.value = "";
377 select.replaceChildren(none, ...names.map((name) => { const option = node("option", "", name); option.value = name; return option; }));
378 if (names.includes(previous)) select.value = previous;
381function loadTemplate() {
382 const name = byId("wizardTemplateSelect").value;
383 byId("wizardTemplateName").value = name;
384 byId("wizardTemplateText").value = storedTemplates()[name] || "";
385 invalidatePlan();
388function saveTemplate() {
389 const name = byId("wizardTemplateName").value.trim();
390 const text = byId("wizardTemplateText").value;
391 if (!name || !text.trim()) { setStatus(t("Zadejte název a text předlohy."), true); return; }
392 if (!writeStored(TEMPLATES_KEY, { ...storedTemplates(), [name]: text })) { setStatus(t("Předlohu se nepodařilo uložit."), true); return; }
393 renderTemplates();
394 byId("wizardTemplateSelect").value = name;
395 setStatus(t("Předloha {name} byla uložena.", { name }));
398function deleteTemplate() {
399 const name = byId("wizardTemplateSelect").value;
400 if (!name || !window.confirm(t("Smazat předlohu {name}?", { name }))) return;
401 const remaining = storedTemplates();
402 delete remaining[name];
403 writeStored(TEMPLATES_KEY, remaining);
404 byId("wizardTemplateSelect").value = "";
405 renderTemplates();
406 loadTemplate();
409// A generated site as a starting point: its concrete values become placeholders again.
410function previewToTemplate() {
411 const site = sitesState.plan;
412 if (!site) return;
413 let text = byId("wizardText").textContent.split("\n").filter((line) => !line.startsWith("# Managed by CH-J")).join("\n").trim();
414 text = text.split(`server_name ${site.serverNames.join(" ")};`).join("server_name {{names}};");
415 if (site.root) text = text.split(site.root).join("{{root}}");
416 text = text.split(site.serverNames[0]).join("{{domain}}");
417 byId("wizardType").value = "custom";
418 byId("wizardTemplateSelect").value = "";
419 byId("wizardTemplateName").value = "";
420 byId("wizardTemplateText").value = `${text}\n`;
421 syncWizard();
422 byId("wizardTemplateName").focus();
425function wizardRequest() {
426 const custom = byId("wizardType").value === "custom";
427 const tls = custom ? "none" : byId("wizardTls").value;
428 const https = tls === "existing";
429 const names = [byId("wizardDomain").value.trim(), ...byId("wizardAliases").value.split(/[\s,]+/)].filter(Boolean);
430 return {
431 type: byId("wizardType").value, serverNames: names, port: Number(byId("wizardPort").value) || 80, ipv6: byId("wizardIpv6").checked,
432 root: byId("wizardRoot").value.trim(), createRoot: byId("wizardCreateRoot").checked, index: byId("wizardIndex").value.trim(),
433 phpBackend: byId("wizardPhp").value.trim(), backend: byId("wizardBackend").value.trim(), backends: byId("wizardBackends").value.split(/\s+/).filter(Boolean), method: byId("wizardMethod").value,
434 connectTimeout: byId("wizardConnectTimeout").value.trim(), readTimeout: byId("wizardReadTimeout").value.trim(),
435 redirectTarget: byId("wizardRedirectTarget").value.trim(), redirectCode: Number(byId("wizardRedirectCode").value),
436 https, certificate: byId("wizardCertificate").value.trim(), certificateKey: byId("wizardKey").value.trim(),
437 redirectHttp: https && byId("wizardRedirectHttp").checked, http2: https && byId("wizardHttp2").checked, http3: https && byId("wizardHttp3").checked, modernTls: https && byId("wizardModernTls").checked,
438 acme: tls === "letsencrypt" || (https && byId("wizardCertificate").value.startsWith("/etc/letsencrypt/")),
439 securityHeaders: byId("wizardHeaders").checked, rateLimit: byId("wizardRateLimit").checked,
440 template: custom ? byId("wizardTemplateText").value : ""
441 };
444const PLAN_WARNINGS = {
445 "certificate-from-letsencrypt": "Certifikát je z Let's Encrypt, web proto obsahuje i umístění pro jeho obnovu.",
446 "http3-needs-udp": "HTTP/3 potřebuje otevřený port 443/UDP ve firewallu serveru.",
447 "selinux-network": "SELinux na tomto serveru brání NGINX v připojení k backendu a web bude vracet chybu 502. Povolíte to příkazem setsebool -P httpd_can_network_connect 1.",
448 "selinux-root": "SELinux dovolí NGINX číst tento adresář jen se správným kontextem (httpd_sys_content_t). Výchozí kontext mají /var/www a /usr/share/nginx.",
449 "selinux-port": "SELinux dovolí NGINX naslouchat na tomto portu až po přidání příkazem semanage port -a -t http_port_t -p tcp PORT."
450};
452async function previewSite() {
453 await siteOperation(t("Sestavuji konfiguraci…"), async (requestGeneration) => {
454 const site = wizardRequest();
455 const plan = await sessionResult(api.nginx.planSite(elements.sessionSelect.value, { site, ...sudoFields() }), requestGeneration);
456 sitesState.plan = site;
457 byId("wizardPath").textContent = `${plan.path} (${plan.link ? t("vznikne vypnutý, zapnutí vytvoří {link}", { link: plan.link }) : t("vznikne vypnutý")})`;
458 byId("wizardText").textContent = plan.text;
459 const warnings = byId("wizardWarnings");
460 warnings.textContent = plan.warnings.map((code) => t(PLAN_WARNINGS[code] || code)).join(" ");
461 warnings.hidden = !plan.warnings.length;
462 byId("wizardResult").hidden = false;
463 byId("wizardDeploy").disabled = false;
464 byId("wizardToTemplate").hidden = site.type === "custom";
465 byId("wizardDeploy").scrollIntoView({ block: "start" });
466 setStatus(t("Náhled je připravený, na serveru se zatím nic nezměnilo."));
467 });
470async function deploySite() {
471 const site = sitesState.plan;
472 if (!site) return;
473 const server = api.sessions.displayName(selectedSession());
474 if (!window.confirm(`${server}\n\n${t("Vytvořit vypnutý web {name} a spustit nginx -t? Při neúspěšném testu se nový soubor zase odstraní. Existující konfigurace se nepřepisuje.", { name: site.serverNames[0] })}`)) return;
475 await siteOperation(t("Vytvářím web a ověřuji konfiguraci…"), async (requestGeneration) => {
476 const result = await sessionResult(api.nginx.createSite(elements.sessionSelect.value, { site, confirm: true, ...sudoFields() }), requestGeneration);
477 setOutput(`${result.testOutput}\n\n${t("Soubor: {path}", { path: result.path })}`);
478 byId("wizard").hidden = true;
479 invalidatePlan();
480 await sessionResult(inspect(), requestGeneration);
481 await loadSites(true);
482 if (requestGeneration !== generation) return;
483 const created = sitesState.inventory?.sites.find((item) => item.serverNames?.includes(site.serverNames[0]));
484 if (created) openDetail(created);
485 setStatus(t("Web byl vytvořen vypnutý a nginx -t uspěl. Zapnete ho tlačítkem Aktivovat, projeví se po plynulém načtení konfigurace."));
486 });
489function openWizard(type) {
490 byId("wizardType").value = type;
491 byId("wizard").hidden = false;
492 byId("siteDetail").hidden = true;
493 syncWizard();
494 byId("wizard").scrollIntoView({ block: "start" });
495 byId("wizardDomain").focus();
498// --- site detail ----------------------------------------------------------------------------------
500function openDetail(site) {
501 sitesState.selected = site;
502 byId("wizard").hidden = true;
503 byId("diffPanel").hidden = true;
504 byId("detailNames").value = (site.serverNames || []).join(" ");
505 byId("detailRoot").value = site.root || "";
506 byId("detailIndex").value = site.index || "";
507 byId("detailProxy").value = site.proxyPass?.[0] || "";
508 byId("detailRedirect").value = (site.redirect?.target || "").replace("$request_uri", "");
509 renderSites();
510 byId("siteDetail").scrollIntoView({ block: "nearest" });
513function renderDetail() {
514 const site = sitesState.selected;
515 byId("siteDetail").hidden = !site || !byId("wizard").hidden;
516 if (!site) return;
517 byId("detailTitle").textContent = siteName(site);
518 const extras = [site.locations ? t("location: {count}", { count: site.locations }) : "", site.fastcgiPass?.length ? `PHP ${site.fastcgiPass.join(", ")}` : "", site.proxyPass?.length ? `→ ${site.proxyPass.join(", ")}` : "", site.aliases?.length ? t("přesměrování z {names}", { names: site.aliases.join(", ") }) : ""].filter(Boolean);
519 byId("detailMeta").textContent = [site.editPath, (site.listen || []).join(" · "), ...extras].join(" · ");
520 byId("detailRootField").hidden = !site.root && site.type !== "static";
521 byId("detailIndexField").hidden = byId("detailRootField").hidden;
522 byId("detailRoot").disabled = Boolean(site.rootFromInclude);
523 byId("detailRoot").title = site.rootFromInclude ? t("Hodnota pochází z vloženého souboru, upravte ji tam.") : "";
524 const plainProxy = site.proxyPass?.length === 1 && /^https?:\/\/[^$]+$/.test(site.proxyPass[0]);
525 byId("detailProxyField").hidden = !plainProxy;
526 byId("detailRedirectField").hidden = site.type !== "redirect" || !/^https?:\/\/[^$]*(\$request_uri)?$/.test(site.redirect?.target || "");
527 byId("detailHttps").hidden = site.https || !site.writable;
528 const toggle = byId("detailToggle");
529 toggle.textContent = site.active ? t("Deaktivovat") : t("Aktivovat");
530 toggle.hidden = site.toggle === "none";
531 byId("detailOpenFile").disabled = !site.writable;
532 byId("detailPlan").disabled = !site.writable;
533 const notice = byId("detailNotice");
534 notice.textContent = !site.writable ? t("Soubor je mimo spravované adresáře, web je jen pro čtení.") : site.adoptable ? t("V sites-enabled je skutečný soubor, ne symlink. Vypnutí ho přesune stranou a zapnutí vrátí zpět.") : "";
535 notice.hidden = !notice.textContent;
538function detailChanges() {
539 const site = sitesState.selected;
540 const changes = {};
541 const names = byId("detailNames").value.trim().split(/\s+/).filter(Boolean);
542 if (names.join(" ") !== (site.serverNames || []).join(" ")) changes.serverNames = names;
543 if (!byId("detailRootField").hidden && byId("detailRoot").value.trim() !== (site.root || "")) changes.root = byId("detailRoot").value.trim();
544 if (!byId("detailIndexField").hidden && byId("detailIndex").value.trim() !== (site.index || "")) changes.index = byId("detailIndex").value.trim();
545 if (!byId("detailProxyField").hidden && byId("detailProxy").value.trim() !== (site.proxyPass?.[0] || "")) changes.proxyPass = byId("detailProxy").value.trim();
546 const target = (site.redirect?.target || "").replace("$request_uri", "");
547 if (!byId("detailRedirectField").hidden && byId("detailRedirect").value.trim() !== target) changes.redirect = { target: byId("detailRedirect").value.trim(), code: Number(site.redirect?.code) };
548 if (!byId("detailHttps").hidden && byId("detailCertificate").value.trim()) changes.https = { certificate: byId("detailCertificate").value.trim(), certificateKey: byId("detailKey").value.trim(), redirectHttp: byId("detailRedirectHttp").checked, http2: byId("detailHttp2").checked };
549 return changes;
552async function planEdit(site, changes) {
553 await siteOperation(t("Připravuji náhled změn…"), async (requestGeneration) => {
554 const plan = await sessionResult(api.nginx.planEdit(elements.sessionSelect.value, { path: site.editPath, ordinal: site.ordinal, changes, ...sudoFields() }), requestGeneration);
555 sitesState.pendingEdit = plan;
556 byId("diffTitle").textContent = plan.path;
557 renderDiff(byId("diffOutput"), plan.before, plan.after);
558 byId("diffPanel").hidden = false;
559 byId("diffPanel").scrollIntoView({ block: "nearest" });
560 setStatus(t("Náhled je připravený, na serveru se zatím nic nezměnilo."));
561 });
564async function applyEdit() {
565 const plan = sitesState.pendingEdit;
566 if (!plan) return;
567 if (!window.confirm(`${api.sessions.displayName(selectedSession())}\n\n${t("Uložit {path}, vytvořit zálohu a spustit nginx -t? Neplatná konfigurace se automaticky vrátí.", { path: plan.path })}`)) return;
568 await siteOperation(t("Ukládám zálohu a ověřuji novou konfiguraci…"), async (requestGeneration) => {
569 const result = await sessionResult(api.nginx.saveConfig(elements.sessionSelect.value, { path: plan.path, text: plan.after, expectedSha256: plan.sha256, ...sudoFields() }), requestGeneration);
570 sitesState.pendingEdit = null;
571 byId("diffPanel").hidden = true;
572 setOutput(`${result.testOutput}\n\n${t("Záloha: {path}", { path: result.backupPath })}`);
573 rememberChange([{ path: plan.path, backup: result.backupPath, sha256: result.sha256 }]);
574 await sessionResult(inspect(), requestGeneration);
575 setStatus(t("Konfigurace byla uložena a nginx -t uspěl. Reload zatím nebyl proveden."));
576 });
579async function toggleSite() {
580 const site = sitesState.selected;
581 if (!site) return;
582 const enabled = !site.active;
583 const question = enabled ? t("Aktivovat web {name} a spustit nginx -t?", { name: siteName(site) }) : t("Deaktivovat web {name} a spustit nginx -t? Soubor webu zůstane na serveru.", { name: siteName(site) });
584 if (!window.confirm(`${api.sessions.displayName(selectedSession())}\n\n${question}`)) return;
585 await siteOperation(t("Měním stav webu a ověřuji konfiguraci…"), async (requestGeneration) => {
586 const path = site.toggle === "link" ? site.editPath : site.path;
587 const result = await sessionResult(api.nginx.setSiteEnabled(elements.sessionSelect.value, { path, linkPath: site.linkPath || "", enabled, confirm: true, ...sudoFields() }), requestGeneration);
588 setOutput(result.testOutput);
589 sitesState.selected = null;
590 await sessionResult(inspect(), requestGeneration);
591 setStatus(t("Stav webu byl změněn a nginx -t uspěl. Projeví se po plynulém načtení konfigurace."));
592 });
595// --- proposed fixes -------------------------------------------------------------------------------
597function proposalQuestion(item) {
598 if (item.kind === "broken-link") return t("Odstranit symlink {path}? Jeho cíl {target} neexistuje a NGINX kvůli němu neprojde testem.", { path: item.path, target: item.target });
599 const older = item.older && !item.identical ? ` ${t("{path} je jiná verze. Nahradí ji načtený soubor, stará verze zůstane v záloze.", { path: item.older })}` : "";
600 return `${t("Přesunout {path} do sites-available a nahradit ho symlinkem? NGINX načte stejnou konfiguraci jako dosud, oba soubory se zálohují.", { path: item.path })}${older}`;
603function proposalText(item) {
604 if (item.kind === "broken-link") return t("Symlink vede na neexistující soubor {target}. Oprava ho odstraní.", { target: item.target });
605 const older = !item.older ? "" : item.identical ? ` ${t("Stejná kopie v sites-available se použije.")}` : ` ${t("V sites-available je jiná verze. Nejdřív si prohlédněte rozdíl: převod by ji nahradil načteným souborem a starou uložil do zálohy.")}`;
606 return `${t("Skutečný soubor v sites-enabled funguje a lze ho vypínat i takto. Sjednocení by ho přesunulo do {path} a vytvořilo symlink.", { path: item.available })}${older}`;
609function proposalDiffers(item) { return Boolean(item.older && !item.identical); }
610function dismissKey() { return `chj.nginx.kept.${serverKey()}`; }
611function visibleProposals() {
612 const kept = readStored(dismissKey(), []);
613 return (sitesState.inventory?.proposals || []).filter((item) => !kept.includes(item.path));
616async function applyProposal(item, requestGeneration) {
617 const session = elements.sessionSelect.value;
618 const result = await sessionResult(item.kind === "broken-link" ? api.nginx.dropBrokenLink(session, { path: item.path, confirm: true, ...sudoFields() }) : api.nginx.adoptSite(session, { path: item.path, confirm: true, ...sudoFields() }), requestGeneration);
619 return result.backupPath ? `${result.testOutput}\n\n${t("Záloha: {path}", { path: result.backupPath })}` : result.testOutput;
622async function fixProposals(items) {
623 if (!items.length) return;
624 const question = items.map(proposalQuestion).join("\n\n");
625 if (!window.confirm(`${api.sessions.displayName(selectedSession())}\n\n${question}`)) return;
626 await siteOperation(t("Opravuji a ověřuji konfiguraci…"), async (requestGeneration) => {
627 // One at a time: a failure stops here and leaves the remaining items as they were.
628 const outputs = [];
629 try { for (const item of items) outputs.push(await applyProposal(item, requestGeneration)); }
630 finally { if (outputs.length) setOutput(outputs.join("\n\n")); }
631 byId("proposalDiff").hidden = true;
632 await sessionResult(inspect(), requestGeneration);
633 await loadSites(true);
634 setStatus(t("Změna proběhla a nginx -t uspěl. Načítaná konfigurace se nezměnila, reload není potřeba."));
635 });
638async function showProposalDiff(item) {
639 await siteOperation(t("Načítám oba soubory…"), async (requestGeneration) => {
640 const session = elements.sessionSelect.value;
641 const older = await sessionResult(api.nginx.readConfig(session, { path: item.older, ...sudoFields() }), requestGeneration);
642 const loaded = await sessionResult(api.nginx.readConfig(session, { path: item.path, ...sudoFields() }), requestGeneration);
643 const target = byId("proposalDiff");
644 renderDiff(target, older.text, loaded.text);
645 target.hidden = false;
646 sitesState.seenDiffs.add(item.path);
647 renderProposals();
648 setStatus(t("Rozdíl: {older} (−) a načtený {loaded} (+).", { older: item.older, loaded: item.path }));
649 });
652function renderProposals() {
653 const items = visibleProposals();
654 byId("proposalsBlock").hidden = !items.length;
655 byId("proposalsFixAll").hidden = items.filter((item) => !proposalDiffers(item)).length < 2;
656 byId("proposalsList").replaceChildren(...items.map((item) => {
657 const row = plainRow(item.path, proposalText(item));
658 const holder = row.querySelector(".badges");
659 if (proposalDiffers(item)) {
660 const diff = node("button", "mini", t("Rozdíl")); diff.type = "button";
661 diff.addEventListener("click", () => void showProposalDiff(item));
662 holder.append(diff);
663 }
664 const fix = node("button", "mini", item.kind === "broken-link" ? t("Opravit") : t("Sjednotit")); fix.type = "button";
665 // A file that differs is not replaced before the difference has been seen.
666 fix.disabled = proposalDiffers(item) && !sitesState.seenDiffs.has(item.path);
667 fix.title = fix.disabled ? t("Nejdřív si prohlédněte rozdíl.") : "";
668 fix.addEventListener("click", () => void fixProposals([item]));
669 holder.append(fix);
670 if (item.kind !== "broken-link") {
671 const keep = node("button", "mini", t("Ponechat takto")); keep.type = "button";
672 keep.addEventListener("click", () => { writeStored(dismissKey(), [...readStored(dismissKey(), []), item.path]); renderProposals(); });
673 holder.append(keep);
674 }
675 return row;
676 }));
679// Line diff over the changed middle part of the file; both ends are usually identical.
680function lineDiff(before, after) {
681 const a = before.split("\n"), b = after.split("\n");
682 let start = 0;
683 while (start < a.length && start < b.length && a[start] === b[start]) start += 1;
684 let endA = a.length, endB = b.length;
685 while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA -= 1; endB -= 1; }
686 const lines = [];
687 for (let at = Math.max(0, start - 3); at < start; at += 1) lines.push(["", a[at]]);
688 const middleA = a.slice(start, endA), middleB = b.slice(start, endB);
689 if (middleA.length * middleB.length > 4_000_000) {
690 middleA.forEach((line) => lines.push(["del", line]));
691 middleB.forEach((line) => lines.push(["add", line]));
692 } else {
693 const table = Array.from({ length: middleA.length + 1 }, () => new Uint32Array(middleB.length + 1));
694 for (let i = middleA.length - 1; i >= 0; i -= 1) for (let j = middleB.length - 1; j >= 0; j -= 1) table[i][j] = middleA[i] === middleB[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
695 let i = 0, j = 0;
696 while (i < middleA.length || j < middleB.length) {
697 if (i < middleA.length && j < middleB.length && middleA[i] === middleB[j]) { lines.push(["", middleA[i]]); i += 1; j += 1; }
698 else if (i < middleA.length && (j === middleB.length || table[i + 1][j] >= table[i][j + 1])) { lines.push(["del", middleA[i]]); i += 1; }
699 else { lines.push(["add", middleB[j]]); j += 1; }
700 }
701 }
702 for (let at = endA; at < Math.min(a.length, endA + 3); at += 1) lines.push(["", a[at]]);
703 return lines;
706function renderDiff(target, before, after) {
707 const lines = lineDiff(before, after);
708 if (!lines.some(([kind]) => kind)) { target.textContent = t("Bez rozdílů."); return; }
709 target.replaceChildren(...lines.map(([kind, line]) => node("span", kind, `${kind === "add" ? "+" : kind === "del" ? "-" : " "} ${line}`)));
712// --- configuration history ------------------------------------------------------------------------
714async function openHistory(path) {
715 if (!path) return;
716 await siteOperation(t("Načítám zálohy…"), async (requestGeneration) => {
717 const result = await sessionResult(api.nginx.backups(elements.sessionSelect.value, { path, ...sudoFields() }), requestGeneration);
718 sitesState.history = result;
719 byId("historyTitle").textContent = path;
720 byId("historyDiff").hidden = true;
721 fill(byId("historyList"), result.backups.map((backup) => {
722 const row = plainRow(new Date(backup.modified * 1000).toLocaleString(i18n.locale()), `${backup.size.toLocaleString(i18n.locale())} B · ${backup.path}`);
723 const holder = row.querySelector(".badges");
724 const show = node("button", "mini", t("Rozdíl")); show.type = "button"; show.addEventListener("click", () => void showBackup(path, backup.path));
725 const restore = node("button", "mini", t("Obnovit")); restore.type = "button"; restore.addEventListener("click", () => void restoreBackup(path, backup.path));
726 holder.append(show, restore);
727 return row;
728 }), t("Tento soubor zatím nemá žádnou zálohu."));
729 byId("historyPanel").hidden = false;
730 setStatus(t("Nalezeno záloh: {count}.", { count: result.backups.length }));
731 });
734async function showBackup(path, backup) {
735 await siteOperation(t("Načítám zálohu…"), async (requestGeneration) => {
736 const result = await sessionResult(api.nginx.readBackup(elements.sessionSelect.value, { path, backup, ...sudoFields() }), requestGeneration);
737 const target = byId("historyDiff");
738 renderDiff(target, result.text, originalText);
739 target.hidden = false;
740 setStatus(t("Rozdíl mezi zálohou (−) a načteným souborem (+)."));
741 });
744async function restoreBackup(path, backup) {
745 if (isDirty()) { setStatus(t("Nejprve uložte nebo zahoďte neuložené změny."), true); return; }
746 if (!window.confirm(`${api.sessions.displayName(selectedSession())}\n\n${t("Obnovit {path} ze zálohy a spustit nginx -t? Současný stav se také zálohuje.", { path })}`)) return;
747 await siteOperation(t("Obnovuji zálohu a ověřuji konfiguraci…"), async (requestGeneration) => {
748 const result = await sessionResult(api.nginx.restoreBackup(elements.sessionSelect.value, { path, backup, expectedSha256: originalSha256, confirm: true, ...sudoFields() }), requestGeneration);
749 setOutput(`${result.testOutput}\n\n${t("Záloha: {path}", { path: result.backupPath })}`);
750 byId("historyPanel").hidden = true;
751 selectedPath = "";
752 await sessionResult(inspect(), requestGeneration);
753 await readConfig(path, "file");
754 setStatus(t("Záloha byla obnovena a nginx -t uspěl. Reload zatím nebyl proveden."));
755 });
758// --- certificates ---------------------------------------------------------------------------------
760function renderAcmeSites() {
761 const select = byId("acmeSite");
762 const previous = select.value;
763 const candidates = listedSites().filter((site) => site.writable && site.serverNames?.some((name) => name !== "_" && !name.startsWith("*")));
764 select.replaceChildren(...candidates.map((site) => { const option = node("option", "", siteName(site)); option.value = `${site.editPath}#${site.ordinal}`; return option; }));
765 if ([...select.options].some((option) => option.value === previous)) select.value = previous;
768function acmeSite() {
769 return httpSites().find((site) => `${site.editPath}#${site.ordinal}` === byId("acmeSite").value) || null;
772function renderCertificates() {
773 const data = sitesState.certificates;
774 const summary = byId("tlsSummary");
775 byId("certbotInstall").hidden = !(data && !data.certbotInstalled && data.certbotInstaller !== "none");
776 if (!data) { summary.textContent = ""; fill(byId("certList"), [], t("Certifikáty zatím nejsou načtené.")); return; }
777 summary.textContent = `${data.certbotInstalled ? t("certbot je nainstalovaný") : t("certbot není nainstalovaný")} · ${data.renewal === "enabled" ? t("automatická obnova je zapnutá") : t("automatická obnova nebyla zjištěna")}`;
778 fill(byId("certList"), data.certificates.map((item) => {
779 const used = (item.usedBy || []).flat().join(", ");
780 const detail = [item.subject, item.issuer, used].filter(Boolean).join(" · ");
781 let state;
782 if (item.state !== "ok") state = badge(item.state === "missing" ? t("Soubor nenalezen") : t("Nelze přečíst"), "bad");
783 else if (item.daysLeft < 0) state = badge(t("Platnost vypršela"), "bad");
784 else state = badge(t("Zbývá dní: {count}", { count: item.daysLeft }), item.daysLeft < 21 ? "warn" : "good");
785 return plainRow(item.path, detail, [state]);
786 }), t("V konfiguraci ani v Let's Encrypt není žádný certifikát."));
789async function loadCertificates(fields = sudoFields()) {
790 await siteOperation(t("Kontroluji certifikáty…"), async (requestGeneration) => {
791 sitesState.certificates = await sessionResult(api.nginx.certificates(elements.sessionSelect.value, fields), requestGeneration);
792 renderSites();
793 setStatus(t("Certifikáty byly zkontrolovány."));
794 });
797function certbotQuestion(allowEpel) {
798 return allowEpel
799 ? t("Certbot není v zapnutých repozitářích tohoto serveru. Přidat repozitář EPEL (balíček epel-release) a nainstalovat certbot?")
800 : t("Nainstalovat certbot přes balíčkový správce serveru? Stáhne jeho balíčky a závislosti z repozitářů serveru a zapne automatickou obnovu certifikátů, pokud ji balíček obsahuje.");
802const CERTBOT_MISSING = /certbot is not installed/i;
803const CERTBOT_NEEDS_EPEL = /needs the EPEL repository/i;
805async function installCertbot(fields = sudoFields(), allowEpel = false, asked = false) {
806 if (!asked && !window.confirm(`${api.sessions.displayName(selectedSession())}\n\n${certbotQuestion(allowEpel)}`)) return false;
807 let needsEpel = false;
808 let installed = false;
809 await siteOperation(t("Instaluji certbot, může to trvat několik minut…"), async (requestGeneration) => {
810 let result;
811 try {
812 result = await sessionResult(api.nginx.installCertbot(elements.sessionSelect.value, { confirm: true, allowEpel, ...fields }), requestGeneration);
813 } catch (error) {
814 if (!CERTBOT_NEEDS_EPEL.test(errorText(error))) throw error;
815 needsEpel = true;
816 return;
817 }
818 sitesState.certificates = await sessionResult(api.nginx.certificates(elements.sessionSelect.value, fields), requestGeneration);
819 renderSites();
820 setOutput(result.output);
821 setStatus(result.renewal === "enabled" ? t("Certbot byl nainstalován a automatická obnova je zapnutá.") : t("Certbot byl nainstalován, ale automatická obnova nebyla zjištěna. Obnovu certifikátů nastavte na serveru ručně."));
822 installed = true;
823 });
824 return needsEpel ? installCertbot(fields, true) : installed;
827async function prepareAcme() {
828 const site = acmeSite();
829 if (!site) { setStatus(t("Vyberte web."), true); return; }
830 switchTab("sites");
831 sitesState.selected = site;
832 renderSites();
833 await planEdit(site, { acme: true });
834 if (!byId("diffPanel").hidden) setStatus(t("Po uložení proveďte plynulé načtení konfigurace, jinak ověření neuspěje."));
837async function issueCertificate() {
838 const site = acmeSite();
839 if (!site) { setStatus(t("Vyberte web."), true); return; }
840 if (!site.active) { setStatus(t("Web je vypnutý. Před vystavením certifikátu ho zapněte a proveďte plynulé načtení konfigurace."), true); return; }
841 if (!byId("acmeAgree").checked) { setStatus(t("Nejprve potvrďte souhlas s podmínkami Let's Encrypt."), true); return; }
842 const domains = site.serverNames.filter((name) => name !== "_");
843 if (!window.confirm(`${api.sessions.displayName(selectedSession())}\n\n${t("Požádat Let's Encrypt o certifikát pro {names}?", { names: domains.join(", ") })}`)) return;
844 const fields = sudoFields();
845 if (await requestCertificate(site, domains, fields) !== "missing") return;
846 await loadCertificates(fields);
847 if (!window.confirm(`${api.sessions.displayName(selectedSession())}\n\n${t("Certbot na tomto serveru chybí.")} ${certbotQuestion(false)}`)) { setStatus(t("Certbot na tomto serveru není nainstalovaný."), true); return; }
848 if (await installCertbot(fields, false, true)) await requestCertificate(site, domains, fields);
851async function requestCertificate(site, domains, fields) {
852 let missing = false;
853 await siteOperation(t("Certbot žádá o certifikát, může to trvat několik minut…"), async (requestGeneration) => {
854 let result;
855 try {
856 result = await sessionResult(api.nginx.issueCertificate(elements.sessionSelect.value, { email: byId("acmeEmail").value.trim(), domains, agreeTos: true, confirm: true, ...fields }), requestGeneration);
857 } catch (error) {
858 if (!CERTBOT_MISSING.test(errorText(error))) throw error;
859 missing = true;
860 return;
861 }
862 setOutput(result.output);
863 setStatus(t("Certifikát byl vystaven. Teď ho zapněte v detailu webu."));
864 switchTab("sites");
865 openDetail(site);
866 byId("detailCertificate").value = result.certificate;
867 byId("detailKey").value = result.certificateKey;
868 });
869 return missing ? "missing" : "done";
872// --- logs -----------------------------------------------------------------------------------------
874function renderLogFiles() {
875 const select = byId("logFile");
876 const previous = select.value;
877 const paths = new Set(sitesState.inventory?.logs || []);
878 for (const site of httpSites()) for (const path of [...(site.accessLog || []), ...(site.errorLog || [])]) if (path.startsWith("/") && !path.includes("$")) paths.add(path);
879 select.replaceChildren(...[...paths].sort().map((path) => { const option = node("option", "", path); option.value = path; return option; }));
880 if (paths.has(previous)) select.value = previous;
883let logText = "";
884function renderLog() {
885 const query = byId("logSearch").value.trim().toLowerCase();
886 const status = byId("logStatus").value;
887 const statusPattern = status ? new RegExp(`" ${status}\\d\\d `) : null;
888 const lines = logText.split("\n").filter((line) => (!query || line.toLowerCase().includes(query)) && (!statusPattern || statusPattern.test(line)));
889 const output = byId("logOutput");
890 output.textContent = lines.join("\n");
891 output.scrollTop = output.scrollHeight;
894async function readLog(silent = false) {
895 const path = byId("logFile").value;
896 if (!path || !elements.sessionSelect.value) return;
897 const requestGeneration = generation;
898 try {
899 const result = await sessionResult(api.nginx.readLog(elements.sessionSelect.value, { path, lines: Number(byId("logLines").value), ...sudoFields() }), requestGeneration);
900 logText = result.text;
901 renderLog();
902 if (!silent) setStatus(t("Log byl načten."));
903 elements.sudoPassword.value = "";
904 } catch (error) {
905 if (requestGeneration !== generation || error?.message === "CHJ_SESSION_CHANGED") return;
906 byId("logFollow").checked = false;
907 syncLogFollow();
908 setStatus(errorText(error), true);
909 }
912function syncLogFollow() {
913 clearInterval(sitesState.logTimer);
914 sitesState.logTimer = 0;
915 if (byId("logFollow").checked) sitesState.logTimer = setInterval(() => { if (!document.hidden && !busy) void readLog(true); }, 3000);
918// --- diagnostics ----------------------------------------------------------------------------------
920function renderDiagnostics() {
921 const inventory = sitesState.inventory;
922 const findings = [];
923 if (inventory) {
924 if (!inventory.dumpOk) findings.push(plainRow(t("Konfigurace neprošla testem"), inventory.testOutput, [badge(t("Chyba"), "bad")]));
925 for (const conflict of inventory.conflicts) findings.push(plainRow(t("{name} na portu {port} je definovaný vícekrát", { name: conflict.name, port: conflict.port }), conflict.sites.join(", "), [badge(t("Konflikt"), "warn")]));
926 for (const problem of inventory.parseErrors) findings.push(plainRow(problem.path, t("Soubor se nepodařilo rozebrat: {message}", { message: problem.message }), [badge(t("Jen editor"), "warn")]));
927 if (inventory.selinux?.mode === "Enforcing" && !inventory.selinux.networkConnect && httpSites().some((site) => site.active && site.proxyPass?.length)) findings.push(plainRow("SELinux", t(PLAN_WARNINGS["selinux-network"]), [badge(t("Upozornění"), "warn")]));
928 for (const file of (inventory.shadowedFiles || []).filter((item) => !item.identical)) findings.push(plainRow(file.path, t("Liší se od načteného souboru {loaded}. NGINX používá {loaded}.", { loaded: file.loaded }), [badge(t("Jiná verze"), "warn")]));
929 for (const site of listedSites().filter((item) => item.active && !item.https && !item.redirect)) findings.push(plainRow(siteName(site), t("Web běží jen přes nešifrované HTTP."), [badge("HTTP", "warn")]));
930 }
931 for (const item of sitesState.certificates?.certificates || []) if (item.state !== "ok" || item.daysLeft < 21) findings.push(plainRow(item.path, item.state === "ok" ? t("Zbývá dní: {count}", { count: item.daysLeft }) : t("Certifikát nelze přečíst."), [badge("TLS", "warn")]));
932 fill(byId("diagFindings"), findings, inventory ? t("Bez nalezených problémů.") : t("Weby zatím nejsou načtené."));
933 const backends = sitesState.backends;
934 fill(byId("diagBackends"), (backends?.backends || []).map((item) => plainRow(item.target, item.sites.join(", "), [badge(item.state === "open" ? t("Dostupný") : item.state === "closed" ? t("Nedostupný") : t("Nezjištěno"), item.state === "open" ? "good" : item.state === "closed" ? "bad" : "warn")])), backends ? t("Konfigurace neuvádí žádný backend.") : t("Spojení zatím nebylo vyzkoušeno."));
935 const capabilities = inspection?.capabilities;
936 const support = (value) => badge(value ? t("Ano") : t("Ne"), value ? "good" : "");
937 const control = capabilities?.controlApi;
938 fill(byId("diagCapabilities"), capabilities ? [
939 plainRow("HTTP/2", t("Podle parametrů sestavení (nginx -V)."), [support(capabilities.http2)]),
940 plainRow("HTTP/3 (QUIC)", t("Podle parametrů sestavení (nginx -V)."), [support(capabilities.http3)]),
941 plainRow("Stream", t("Podle parametrů sestavení (nginx -V)."), [support(capabilities.stream)]),
942 plainRow("Control API", control.versionSupported ? t("Tato verze ho může obsahovat. CH-J ho nezapíná: API nemá vlastní ověření a vyžaduje zabezpečený přístup.") : t("Vyžaduje NGINX 1.31.5 nebo novější."), [support(control.versionSupported)])
943 ] : [], t("Weby zatím nejsou načtené."));
946async function probeBackends() {
947 await siteOperation(t("Zkouším spojení s backendy…"), async (requestGeneration) => {
948 sitesState.backends = await sessionResult(api.nginx.probeBackends(elements.sessionSelect.value, sudoFields()), requestGeneration);
949 renderDiagnostics();
950 setStatus(sitesState.backends.omitted ? t("Hotovo. Vyzkoušeno prvních 32 backendů, {count} dalších ne.", { count: sitesState.backends.omitted }) : t("Hotovo."));
951 });
954// --- tabs and wiring ------------------------------------------------------------------------------
956function switchTab(name) {
957 for (const tab of document.querySelectorAll(".tab")) { tab.classList.toggle("active", tab.dataset.tab === name); tab.setAttribute("aria-selected", String(tab.dataset.tab === name)); }
958 for (const panel of document.querySelectorAll(".tab-panel")) panel.hidden = panel.dataset.panel !== name;
959 document.querySelector(".tabs").scrollIntoView({ block: "nearest" });
962function resetSites() {
963 Object.assign(sitesState, { inventory: null, certificates: null, selected: null, plan: null, pendingEdit: null, backends: null, history: null, route: null, routePlan: null });
964 logText = "";
965 const logFollow = byId("logFollow");
966 if (logFollow) { logFollow.checked = false; syncLogFollow(); byId("logOutput").textContent = ""; }
967 for (const id of ["wizard", "diffPanel", "historyPanel", "proposalDiff", "routeEditor"]) byId(id).hidden = true;
968 sitesState.seenDiffs = new Set();
969 rememberChange([]);
970 showSitesNotice("");
971 renderSites();
974function initSites() {
975 for (const tab of document.querySelectorAll(".tab")) tab.addEventListener("click", () => switchTab(tab.dataset.tab));
976 byId("loadSitesButton").addEventListener("click", () => siteOperation(t("Načítám weby…"), () => loadSites()));
977 byId("newSiteButton").addEventListener("click", () => openWizard("static"));
978 byId("proxyNewButton").addEventListener("click", () => { switchTab("sites"); openWizard("proxy"); });
979 byId("wizardClose").addEventListener("click", () => { byId("wizard").hidden = true; renderDetail(); });
980 byId("wizardPreview").addEventListener("click", previewSite);
981 byId("wizardDeploy").addEventListener("click", deploySite);
982 byId("wizard").addEventListener("input", (event) => {
983 if (event.target.id === "wizardRoot" || event.target.id === "wizardIndex") event.target.dataset.edited = "1";
984 syncWizard();
985 });
986 byId("detailClose").addEventListener("click", () => { sitesState.selected = null; byId("diffPanel").hidden = true; renderSites(); });
987 byId("detailPlan").addEventListener("click", () => {
988 const changes = detailChanges();
989 if (!Object.keys(changes).length) { setStatus(t("Nebyla provedena žádná změna.")); return; }
990 void planEdit(sitesState.selected, changes);
991 });
992 byId("detailToggle").addEventListener("click", toggleSite);
993 byId("proposalsFixAll").addEventListener("click", () => void fixProposals(visibleProposals().filter((item) => !proposalDiffers(item))));
994 byId("routeAdd").addEventListener("click", () => openRouteEditor(null));
995 byId("routeEditorClose").addEventListener("click", () => { byId("routeEditor").hidden = true; });
996 byId("routePlan").addEventListener("click", () => {
997 const change = routeChange();
998 if (!change.host) { setStatus(t("Zadejte doménu."), true); return; }
999 void planRoute(change);
1000 });
1001 byId("routeApply").addEventListener("click", applyRoute);
1002 for (const id of ["routeHost", "routeHttp", "routeTls"]) byId(id).addEventListener("input", () => { sitesState.routePlan = null; byId("routeDiff").hidden = true; });
1003 byId("routeHttp").addEventListener("change", () => {
1004 // Both protocols usually go to the same machine.
1005 const host = byId("routeHttp").value.trim().replace(/:\d+$/, "");
1006 if (!sitesState.route && host && !byId("routeTls").value.trim()) byId("routeTls").value = `${host}:443`;
1007 });
1008 byId("undoButton").addEventListener("click", undoLastChange);
1009 byId("diagDomainRun").addEventListener("click", diagnoseDomain);
1010 byId("wizardTemplateSelect").addEventListener("change", loadTemplate);
1011 byId("wizardTemplateSave").addEventListener("click", saveTemplate);
1012 byId("wizardTemplateDelete").addEventListener("click", deleteTemplate);
1013 byId("wizardToTemplate").addEventListener("click", previewToTemplate);
1014 renderTemplates();
1015 byId("detailOpenFile").addEventListener("click", () => { const site = sitesState.selected; if (site) { switchTab("config"); void readConfig(site.editPath, "file"); } });
1016 byId("diffCancel").addEventListener("click", () => { sitesState.pendingEdit = null; byId("diffPanel").hidden = true; });
1017 byId("diffApply").addEventListener("click", applyEdit);
1018 byId("historyClose").addEventListener("click", () => { byId("historyPanel").hidden = true; });
1019 byId("loadCertsButton").addEventListener("click", () => void loadCertificates());
1020 byId("certbotInstallButton").addEventListener("click", () => void installCertbot());
1021 byId("acmePrepare").addEventListener("click", prepareAcme);
1022 byId("acmeIssue").addEventListener("click", issueCertificate);
1023 byId("logRead").addEventListener("click", () => void readLog());
1024 byId("routeFilter").addEventListener("input", () => renderRoutes());
1025 byId("logSearch").addEventListener("input", renderLog);
1026 byId("logStatus").addEventListener("change", renderLog);
1027 byId("logFollow").addEventListener("change", syncLogFollow);
1028 byId("diagTest").addEventListener("click", runTest);
1029 byId("diagProbe").addEventListener("click", probeBackends);
1030 syncWizard();
SHA-25616ff417dd7152863414c8566addc837ed680a2e9baf23cd884e75a93369cd8e9

CH-J Proprietary Software License 1.14 · Источник представлен в CH-J Proprietary Software License 1.14. Его наличие не изменяет лицензионных условий и не предоставляет дополнительных прав.

Архив SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Файлы