Veröffentlichte Quellen
Quellen als ZIP herunterladen
CH-J Tools
Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.
CH-J / Server Manager
1704 Dateien
0.1.2-beta
Hinzugefügt: · Erstellt:
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.5
const 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() };6
const byId = (id) => document.getElementById(id);7
const TYPE_LABELS = { static: "Statický web", spa: "SPA", php: "PHP", proxy: "Reverse proxy", redirect: "Přesměrování", other: "Jiná konfigurace", stream: "Stream" };8
const 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
};19
function 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;24
}26
function sudoFields() { return { sudoPassword: sudoPassword(), rememberSudo: rememberSudo() }; }27
function errorText(error) { return String(error?.message || error).replace(/^Error invoking remote method '[^']*': Error: /, ""); }28
function needsPassword(error) { return /requires the sudo password/i.test(errorText(error)); }29
function siteName(site) { return site.serverNames?.find((name) => name !== "_") || site.path; }30
function 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.32
function listedSites() { return httpSites().filter((site) => site.role === "site" || site.role === "redirect"); }33
function 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.35
function 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));45
}46
function serverKey() { return selectedSession()?.host || ""; }47
function readStored(key, fallback) { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } }48
function writeStored(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch { return false; } }49
function 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.52
async 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); } }61
}63
async 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
}77
}79
function showSitesNotice(text) {80
const notice = byId("sitesNotice");81
notice.textContent = text || "";82
notice.hidden = !text;83
}85
function badge(text, tone = "") { return node("span", `badge${tone ? ` ${tone}` : ""}`, text); }87
function 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;91
}93
function 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;107
}109
function fill(container, items, emptyText) {110
if (items.length) container.replaceChildren(...items);111
else container.replaceChildren(node("p", "empty", emptyText));112
}114
function 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;122
}124
function 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;131
}133
function 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();163
}165
function 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
}));186
}188
// --- route editor ---------------------------------------------------------------------------------190
function 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" });203
}205
function 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;212
}214
const 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
};220
async 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
});237
}239
async 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 });243
}245
async 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
});261
}263
// --- undoing the last change ----------------------------------------------------------------------265
function rememberChange(items) {266
sitesState.lastChange = items;267
byId("undoButton").hidden = !items.length;268
}270
async 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
});281
}283
// --- path of a domain -----------------------------------------------------------------------------285
function 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;292
}294
function stateBadge(state) {295
return state === "open" ? badge(t("TCP dostupné"), "good") : state === "closed" ? badge(t("TCP nedostupné"), "bad") : badge(t("Nezjištěno"), "warn");296
}298
const RULE_KINDS = { exact: "přesný název", wildcard: "zástupný název", regex: "regulární výraz", default: "výchozí pravidlo" };300
async 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
});326
}328
// Everything that can be opened: files on disk, and what NGINX loaded, also from outside its directory.329
function 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));337
}339
function externalConfigText(path) { return (sitesState.inventory?.includedFiles || []).find((file) => file.path === path)?.text ?? null; }341
// --- wizard ---------------------------------------------------------------------------------------343
function 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();360
}362
function invalidatePlan() {363
sitesState.plan = null;364
byId("wizardDeploy").disabled = true;365
byId("wizardResult").hidden = true;366
byId("wizardToTemplate").hidden = true;367
}369
const TEMPLATES_KEY = "chj.nginx.templates";370
function storedTemplates() { const value = readStored(TEMPLATES_KEY, {}); return value && typeof value === "object" ? value : {}; }372
function 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;379
}381
function loadTemplate() {382
const name = byId("wizardTemplateSelect").value;383
byId("wizardTemplateName").value = name;384
byId("wizardTemplateText").value = storedTemplates()[name] || "";385
invalidatePlan();386
}388
function 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 }));396
}398
function 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();407
}409
// A generated site as a starting point: its concrete values become placeholders again.410
function 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();423
}425
function 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
};442
}444
const 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
};452
async 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
});468
}470
async 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
});487
}489
function 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();496
}498
// --- site detail ----------------------------------------------------------------------------------500
function 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" });511
}513
function 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;536
}538
function 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;550
}552
async 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
});562
}564
async 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
});577
}579
async 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
});593
}595
// --- proposed fixes -------------------------------------------------------------------------------597
function 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}`;601
}603
function 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}`;607
}609
function proposalDiffers(item) { return Boolean(item.older && !item.identical); }610
function dismissKey() { return `chj.nginx.kept.${serverKey()}`; }611
function visibleProposals() {612
const kept = readStored(dismissKey(), []);613
return (sitesState.inventory?.proposals || []).filter((item) => !kept.includes(item.path));614
}616
async 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;620
}622
async 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
});636
}638
async 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
});650
}652
function 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
}));677
}679
// Line diff over the changed middle part of the file; both ends are usually identical.680
function 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;704
}706
function 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}`)));710
}712
// --- configuration history ------------------------------------------------------------------------714
async 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
});732
}734
async 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
});742
}744
async 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
});756
}758
// --- certificates ---------------------------------------------------------------------------------760
function 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;766
}768
function acmeSite() {769
return httpSites().find((site) => `${site.editPath}#${site.ordinal}` === byId("acmeSite").value) || null;770
}772
function 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."));787
}789
async 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
});795
}797
function certbotQuestion(allowEpel) {798
return allowEpel799
? 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.");801
}802
const CERTBOT_MISSING = /certbot is not installed/i;803
const CERTBOT_NEEDS_EPEL = /needs the EPEL repository/i;805
async 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;825
}827
async 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."));835
}837
async 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);849
}851
async 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";870
}872
// --- logs -----------------------------------------------------------------------------------------874
function 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;881
}883
let logText = "";884
function 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;892
}894
async 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
}910
}912
function 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);916
}918
// --- diagnostics ----------------------------------------------------------------------------------920
function 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é."));944
}946
async 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
});952
}954
// --- tabs and wiring ------------------------------------------------------------------------------956
function 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" });960
}962
function 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();972
}974
function 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();1031
}SHA-256
16ff417dd7152863414c8566addc837ed680a2e9baf23cd884e75a93369cd8e9CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.
SHA-256 des Archivs
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f