CH-J Toolspengurusan pelayan melalui SSH
Menu
Sumber yang diterbitkan

CH-J Tools

Layari direktori dan fail untuk keluaran aplikasi tertentu.

Muat turun ZIP sumber
CH-J / Server Manager 1704 fail
0.1.2-beta Ditambah: · Dibina:
index.js Ditambah: Tarikh tidak direkodkan 51.5 KB · 710 baris
Kod Muat turun fail
1"use strict";
3const api = window.chjPlugin;
4const i18n = window.chjPluginI18n?.createI18n("cs");
5const t = (key, params = {}) => i18n ? i18n.t(key, params) : String(key).replace(/\{(\w+)\}/g, (all, name) => String(params[name] ?? all));
6const byId = (id) => document.getElementById(id);
7let sessions = [], sessionId = "", endedSessionId = "", sessionsLoading = false;
8let generation = 0, revision = 0, planRequest = 0, inspectRequest = 0, diagnosticRequest = 0;
9let inventory = null, selected = null, plan = null, transaction = null;
10let busy = false, planning = false, diagnosticBusy = false;
12function node(tag, className = "", text) {
13 const element = document.createElement(tag);
14 if (className) element.className = className;
15 if (text !== undefined) element.textContent = String(text);
16 return element;
18function badge(text, tone = "") { return node("span", `badge ${tone}`, text); }
19function row(title, detail = "", badges = [], onClick) {
20 const element = node(onClick ? "button" : "div", "site-row");
21 if (onClick) { element.type = "button"; element.addEventListener("click", onClick); }
22 const main = node("span", "site-main");
23 main.append(node("strong", "", title), node("small", "", detail));
24 const holder = node("span", "badges"); holder.append(...badges);
25 element.append(main, holder);
26 return element;
28function fill(id, rows, empty = t("Žádné záznamy.")) {
29 byId(id).replaceChildren(...(rows.length ? rows : [node("p", "empty", inventory ? empty : t("Údaje zatím nejsou načtené."))]));
31function notice(id, text) { byId(id).textContent = text; byId(id).hidden = !text; }
32function setStatus(text, error = false) { byId("status").textContent = text; byId("status").classList.toggle("error", error); }
33function errorText(error) { return String(error?.message || error).replace(/^Error invoking remote method '[^']*': Error: /, ""); }
34function takeSecret(id) { const value = byId(id).value; byId(id).value = ""; return value; }
35function auth() { return { sudoPassword: takeSecret("sudoPassword") }; }
36function credentials() { return { password: takeSecret("verifyPassword"), passphrase: takeSecret("verifyPassphrase") }; }
37function connected(id = sessionId) { return Boolean(id) && sessions.some((s) => s.sessionId === id && s.state === "connected"); }
38function selectedSession() { return sessions.find((s) => s.sessionId === sessionId); }
39function terminal(tx = transaction) { return tx?.state === "confirmed" || tx?.state === "rolled-back" || tx?.state === "failed"; }
40function changing() { return Boolean(transaction && !terminal()); }
41function syncControls() {
42 const ready = connected() && Boolean(inventory) && !busy && !planning && !changing();
43 byId("loadButton").disabled = !connected() || busy;
44 byId("refreshButton").disabled = sessionsLoading;
45 byId("planInterface").disabled = !ready || !selected;
46 for (const id of ["planRule", "planPolicy", "planProfile"]) byId(id).disabled = !ready || inventory?.firewall?.readable !== true || !editableBackend();
47 for (const id of ["runDiagnostics", "checkPort"]) byId(id).disabled = !connected() || !inventory || diagnosticBusy;
48 byId("applyPlan").disabled = !canApply();
49 for (const button of document.querySelectorAll("[data-remove-rule]")) button.disabled = !ready;
51function resetSession(id) {
52 generation += 1; inspectRequest += 1; diagnosticRequest += 1;
53 sessionId = id; inventory = null; selected = null; busy = false; planning = false; diagnosticBusy = false;
54 invalidatePlan();
55 takeSecret("sudoPassword");
56 byId("interfaceEditor").hidden = true;
57 byId("diagnosticResults").replaceChildren();
58 for (const id of ["pendingNotice", "sudoNotice"]) notice(id, "");
59 renderAll();
61async function loadSessions() {
62 if (sessionsLoading) return;
63 sessionsLoading = true;
64 try {
65 sessions = (await api.sessions.list()).filter((session) => session.state === "connected");
66 // Reconcile against the selection NOW, not the selection when list() started.
67 const next = api.sessions.reconcile(endedSessionId || sessionId, sessions);
68 const select = byId("sessionSelect");
69 select.replaceChildren(new Option(sessions.length ? t("Vyberte relaci…") : t("Žádná připojená relace"), ""));
70 for (const session of sessions) select.add(new Option(api.sessions.displayName(session), session.sessionId));
71 select.value = next.id;
72 if (next.reason === "choose") api.sessions.offerChoice(select, sessions, t("Vyberte relaci…"));
73 if (next.reason === "gone") endedSessionId ||= sessionId;
74 if (next.id !== sessionId) { resetSession(next.id); if (next.id) void inspect(); }
75 if (next.reason === "gone") setStatus(t("Relace skončila. Vyberte server."), true);
76 } catch (error) { setStatus(errorText(error), true); }
77 finally { sessionsLoading = false; syncControls(); }
79async function inspect() {
80 if (!connected()) return;
81 const own = generation, request = ++inspectRequest, id = sessionId;
82 busy = true; invalidatePlan(); const editorRevision = revision; syncControls(); setStatus(t("Načítám síť a firewall…"));
83 const results = await Promise.allSettled([
84 api.network.inspect({ sessionId: id, ...auth() }),
85 api.network.pending({ sessionId: id })
86 ]);
87 if (own !== generation || request !== inspectRequest) return;
88 busy = false;
89 const [reading, pending] = results;
90 if (reading.status === "fulfilled") {
91 inventory = reading.value;
92 selected = inventory.interfaces?.find((item) => item.name === selected?.name) || null;
93 renderAll();
94 if (selected && editorRevision === revision) openInterface(selected);
95 if (!selected) byId("interfaceEditor").hidden = true;
96 notice("sudoNotice", inventory.sudoError || inventory.firewall?.readable === false ? t("Firewall nelze přečíst. Zadejte heslo pro sudo a znovu klikněte na Načíst.") : "");
97 setStatus(t("Síť a firewall načteny."));
98 } else {
99 inventory = null; selected = null; byId("interfaceEditor").hidden = true; renderAll();
100 setStatus(errorText(reading.reason), true);
101 }
102 if (pending.status === "fulfilled") {
103 const item = pending.value;
104 notice("pendingNotice", item?.id ? t("Na serveru čeká změna {id}. Stav: {state}.", { id: item.id, state: stateText(item.state) }) : "");
105 if (item?.id && (!changing() || transaction?.untracked) && transaction?.id !== item.id) startTransaction({ id: item.id, state: item.state, sessionId: id, server: api.sessions.displayName(selectedSession()), resumed: true });
106 if (!item?.id && transaction?.untracked && transaction.sessionId === id) {
107 transaction.state = "failed"; transaction.untracked = false;
108 transaction.message = t("Server nehlásí čekající změnu. Ověřte aktuální stav v načteném přehledu a původní připojení.");
109 renderTransaction();
110 }
111 } else notice("pendingNotice", t("Čekající změnu se nepodařilo zjistit: {message}", { message: errorText(pending.reason) }));
112 syncControls();
114function kindText(kind) {
115 switch (kind) {
116 case "loopback": return t("Smyčka"); case "ethernet": return t("Ethernet"); case "bridge": return t("Most");
117 case "bond": return t("Sdružené rozhraní"); case "vlan": return t("VLAN"); case "wireguard": return t("WireGuard");
118 case "tun": return t("Tunel"); case "veth": return t("Virtuální pár"); case "dummy": return t("Virtuální rozhraní");
119 case "wifi": return t("Wi-Fi"); default: return t("Jiné rozhraní");
120 }
122function stateText(state) {
123 switch (state) {
124 case "confirmed": return t("Potvrzeno"); case "rolled-back": return t("Vráceno"); case "applying": return t("Provádění změny");
125 case "verified": return t("Spojení ověřeno"); case "pending": case "waiting": case "applied": return t("Čeká na potvrzení");
126 case "rolling-back": return t("Obnovování původního stavu"); default: return t("Stav: {state}", { state: state || t("Nezjištěno") });
127 }
129function actionText(action) {
130 switch (action) { case "accept": return t("Povolit"); case "drop": return t("Zahodit"); case "reject": return t("Odmítnout"); case "log": return t("Zaznamenat"); default: return t("Nezjištěno"); }
132function directionText(direction) { return direction === "in" ? t("Příchozí") : direction === "out" ? t("Odchozí") : t("Předávaný"); }
133function addressText(a) { return `${a.address}${a.prefix == null ? "" : `/${a.prefix}`}`; }
134function renderInterfaces() {
135 const list = inventory?.interfaces || [];
136 byId("statCount").textContent = inventory ? list.length : "—";
137 byId("statActive").textContent = inventory ? list.filter((i) => i.state === "up").length : "—";
138 byId("statIPv6").textContent = inventory ? list.filter((i) => i.addresses?.some((a) => a.family === 6)).length : "—";
139 byId("statManager").textContent = inventory?.config?.manager || "—";
140 fill("interfaceList", list.map((item) => {
141 const marks = [badge(item.state === "up" ? t("Aktivní") : item.state === "down" ? t("Neaktivní") : t("Nezjištěno"), item.state === "up" ? "good" : "")];
142 if (item.ssh || inventory.ssh?.interface === item.name) marks.push(badge(t("Aktuální SSH – rizikové"), "warn"));
143 const carrier = item.carrier === true ? t("Linka připojena") : item.carrier === false ? t("Bez linky") : t("Linka nezjištěna");
144 const speed = item.speedMbps == null ? "" : t("{speed} Mb/s", { speed: item.speedMbps });
145 const element = row(item.name, [kindText(item.kind), speed, carrier, ...(item.addresses || []).map(addressText)].filter(Boolean).join(" · "), marks, () => openInterface(item));
146 element.classList.toggle("active", item.name === selected?.name);
147 return element;
148 }));
150function openInterface(item) {
151 invalidatePlan(); selected = item;
152 byId("interfaceEditor").hidden = false;
153 byId("interfaceMeta").textContent = t("{name} · spravuje {manager} · {file}", { name: item.name, manager: item.managedBy || inventory?.config?.manager || t("Nezjištěno"), file: item.configuredIn || item.methodSource || t("Soubor nezjištěn") });
154 byId("sshNotice").hidden = !(item.ssh || inventory?.ssh?.interface === item.name);
155 for (const family of [4, 6]) {
156 const known = item[`ipv${family}Method`];
157 // Only link-local IPv6 says nothing about the method; the kernel's default behaves like SLAAC. The diff shows it.
158 byId(`ipv${family}Method`).value = known && known !== "unknown" ? known : family === 6 ? "slaac" : "unknown";
159 byId(`ipv${family}Addresses`).value = (item.addresses || []).filter((a) => a.family === family && a.scope !== "link" && a.scope !== "host").map(addressText).join("\n");
160 byId(`gateway${family}`).value = item[`gateway${family}`] || "";
161 }
162 // What DHCP hands out must not become a fixed setting by being shown here.
163 byId("interfaceDns").value = byId("ipv4Method").value === "dhcp" ? "" : (item.dns || []).join("\n");
164 byId("interfaceSearch").value = byId("ipv4Method").value === "dhcp" ? "" : (inventory?.dns?.search || []).join("\n");
165 byId("interfaceMtu").value = item.mtu && item.mtu !== 1500 ? item.mtu : "";
166 syncMethods(); renderInterfaces(); syncControls();
168function syncMethods() {
169 for (const family of [4, 6]) {
170 const method = byId(`ipv${family}Method`).value;
171 byId(`ipv${family}Addresses`).disabled = method !== "static";
172 byId(`gateway${family}`).disabled = method !== "static" || method === "unknown";
173 }
175function renderRoutes() {
176 fill("routeList", (inventory?.routes || []).map((r) => row(r.destination || (r.default ? "default" : "—"), t("IPv{family} · přes {gateway} · {device} · metrika {metric}", { family: r.family, gateway: r.gateway || "—", device: r.device || "—", metric: r.metric ?? "—" }), r.default ? [badge(t("Výchozí"), "good")] : [])));
177 fill("dnsList", inventory ? [row(t("DNS servery"), (inventory.dns?.servers || []).join(", ") || "—"), row(t("Vyhledávací domény"), (inventory.dns?.search || []).join(", ") || "—"), row(t("Zdroj DNS"), inventory.dns?.source || "—")] : []);
178 fill("listenerList", listenerRows());
180function editableBackend() {
181 const active = inventory?.firewall?.active;
182 return active && active !== "none" && active !== "unknown" ? active : byId("firewallBackend").value;
184// One row for a service, not one for each of its addresses and IP versions.
185function listenerRows() {
186 const grouped = new Map();
187 for (const item of inventory?.listening || []) {
188 const protocol = String(item.protocol).replace(/6$/, ""), processes = item.processes || [];
189 const key = `${protocol}:${item.port}:${processes.join(",")}`;
190 if (!grouped.has(key)) grouped.set(key, { protocol, port: item.port, processes, addresses: [] });
191 const entry = grouped.get(key);
192 if (!entry.addresses.includes(item.address)) entry.addresses.push(item.address);
193 }
194 const rows = [...grouped.values()].sort((a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol))
195 .map((item) => row([item.protocol, item.port, ...(item.processes.length ? [item.processes.join(", ")] : [])].join(" · "), item.addresses.join(", ")));
196 if (rows.length && inventory?.sudoError) rows.unshift(row(t("Bez sudo nejde zjistit, který program port používá."), ""));
197 return rows;
199function removable(rule) {
200 // Only the tool's own list. Its internal chains and rules of other tools stay as they are.
201 if (rule.managed !== true || !rule.id || rule.backend !== editableBackend()) return false;
202 if (rule.direction === "forward") return false;
203 if (rule.backend === "ufw") return Number(rule.number) > 0;
204 if (rule.backend === "firewalld") return ["port", "service", "rich"].includes(rule.kind);
205 return rule.backend === "nftables" || rule.backend === "iptables";
207function visibleRules(fw) {
208 const zones = new Set((fw?.zones || []).filter((zone) => zone.active).map((zone) => zone.name));
209 return (fw?.rules || []).filter((rule) => byId("showPlumbing").checked || (!rule.plumbing && (!rule.zone || !zones.size || zones.has(rule.zone))));
212function populateSelect(id, items, label) {
213 const select = byId(id), value = select.value;
214 select.replaceChildren(new Option(label, ""), ...items.map((s) => new Option(s, s)));
215 select.value = items.includes(value) ? value : "";
217function renderFirewall() {
218 const fw = inventory?.firewall;
219 const active = (fw?.backends || []).filter((b) => b.active);
220 const candidates = fw?.active && !["none", "unknown"].includes(fw.active) ? [fw.active] : (fw?.backends || []).filter((b) => b.installed).map((b) => b.name);
221 const backendSelect = byId("firewallBackend"), previous = backendSelect.value;
222 backendSelect.replaceChildren(new Option(t("Vyberte nástroj…"), ""), ...candidates.map((name) => new Option(name, name)));
223 backendSelect.value = candidates.includes(previous) ? previous : candidates.length === 1 ? candidates[0] : "";
224 backendSelect.disabled = active.length > 0;
225 byId("firewallMeta").textContent = fw ? t("Spravuje: {tool}. Pravidla: {readable}. Vstup: {input}, výstup: {output}, předávání: {forward}.", { tool: fw.active === "none" ? t("Žádný firewall") : fw.active || t("Nezjištěno"), readable: fw.readable ? t("Čitelná") : t("Nedostupná"), input: actionText(fw.defaultPolicy?.input), output: actionText(fw.defaultPolicy?.output), forward: actionText(fw.defaultPolicy?.forward) }) : "—";
226 fill("backendList", (fw?.backends || []).filter((b) => b.installed || b.active).map((b) => row(b.name, b.detail || "", [badge(b.active ? t("Aktivní") : t("Neaktivní"), b.active ? "good" : ""), badge(b.enabled === true ? t("Spouští se při startu") : b.enabled === false ? t("Start nezapnut") : t("Spouštění nezjištěno"))])));
227 notice("firewallWarning", active.length > 1 || (inventory?.findings || []).some((f) => f.code === "several-firewalls") ? t("Běží několik firewallů. Nezapínejte další; ověřte, který má pravidla spravovat.") : "");
228 notice("runtimeNotice", fw?.differs || fw?.runtimeDiffers || (fw?.findings || []).some((f) => f.code === "firewall-runtime-differs") ? t("Běžící pravidla se liší od trvalé konfigurace. Zkontrolujte zóny a návrh před uložením.") : "");
229 fill("zoneList", (fw?.zones || []).map((z) => row(z.name, [z.target, ...(z.interfaces || []), ...(z.sources || []), ...(z.services || []), ...(z.ports || [])].filter(Boolean).join(" · "), z.active ? [badge(t("Aktivní"), "good")] : [])));
230 populateSelect("ruleInterface", (inventory?.interfaces || []).map((i) => i.name), t("Všechna rozhraní"));
231 populateSelect("ruleZone", (fw?.zones || []).map((z) => z.name), t("Výchozí zóna"));
232 byId("ruleZone").disabled = !["firewalld", "fw4"].includes(fw?.active);
233 const hidden = (fw?.rules || []).length - visibleRules(fw).length;
234 byId("hiddenRules").textContent = hidden ? t("Skryto: {count} interních pravidel nástroje a pravidel neaktivních zón. Zůstávají beze změny.", { count: hidden }) : "";
235 fill("ruleList", visibleRules(fw).map((rule) => {
236 const opaque = rule.understood === false;
237 const element = row(opaque ? t("Nerozpoznané pravidlo – zachováno") : `${directionText(rule.direction)} · ${actionText(rule.action)} · ${rule.protocol || "—"} ${(rule.ports || []).join(", ")}`, [rule.backend, t("Rodina: {family}", { family: rule.family }), rule.source, rule.destination, rule.interface, rule.zone, rule.comment].filter(Boolean).join(" · "), [badge(directionText(rule.direction))]);
238 if (rule.direction === "forward") element.lastChild?.append(badge(t("Pouze pro čtení")));
239 if (opaque) element.firstChild.append(node("pre", "command rule-raw", rule.raw || "—"));
240 if (removable(rule)) {
241 const button = node("button", "mini", t("Navrhnout odstranění")); button.type = "button"; button.dataset.removeRule = rule.id;
242 button.addEventListener("click", () => void requestPlan("firewall", { remove: [rule.id], backend: rule.backend, zone: rule.zone || undefined })); element.append(button);
243 }
244 return element;
245 }));
246 fill("natList", (fw?.nat || []).map((item) => {
247 const entry = row(t("NAT / předávání"), "", [badge(t("Pouze pro čtení"))]);
248 entry.firstChild.append(node("pre", "command", typeof item === "string" ? item : JSON.stringify(item, null, 2)));
249 return entry;
250 }));
253function findingText(item) {
254 const p = item.params || {};
255 switch (item.code) {
256 case "several-network-managers": return [t("Síť spravuje více nástrojů."), t("Ověřte správce každého rozhraní. Souběžná instalace sama o sobě není chyba.")];
257 case "no-default-route": return [t("Chybí výchozí trasa IPv4."), t("Pokud server potřebuje cestu mimo místní síť, nastavte bránu.")];
258 case "no-default-route-v6": return [t("Globální IPv6 adresa nemá výchozí trasu."), t("Ověřte oznámení směrovače a bránu IPv6.")];
259 case "interface-no-carrier": return [t("Rozhraní {interface} nemá linku.", { interface: p.interface || p.name || "—" }), t("Zkontrolujte kabel, přepínač nebo virtuální síť. Odpojené rozhraní může být záměr.")];
260 case "needs-root": return [t("Firewall vyžaduje oprávnění root."), t("Zadejte heslo pro sudo a znovu načtěte údaje.")];
261 case "several-firewalls": return [t("Pravidla spravuje více aktivních firewallů."), t("Určete jeden nástroj pro správu. Další firewall nezapínejte.")];
262 case "firewall-runtime-differs": return [t("Běžící a trvalá pravidla se liší."), t("Porovnejte zóny firewalld před ukládáním a restartem.")];
263 case "ssh-may-be-blocked": return [t("Firewall může blokovat SSH na portu {port}.", p), t("Povolte port této relace ze správcovské sítě a ověřte nové spojení.")];
264 case "icmpv6-blocked": return [t("ICMPv6 může být blokované. Nefunguje pak hledání sousedů ani zjišťování MTU."), t("Povolte potřebné ICMPv6 zprávy podle pravidel vaší sítě.")];
265 case "no-firewall": return [t("Není aktivní firewall."), t("Zvažte ochranu podle role serveru a pravidel nadřazené sítě.")];
266 case "ufw-inactive": return [t("UFW je neaktivní."), t("Ověřte, zda provoz chrání jiný nástroj. UFW nezapínejte souběžně s ním.")];
267 default: return [t("Neznámý nález: {code}", { code: item.code }), t("Prověřte podrobnosti na serveru před změnou konfigurace.")];
268 }
270function levelText(level) { return level === "error" ? t("Chyba") : level === "warning" ? t("Varování") : t("Doporučení"); }
271function renderFindings() {
272 const findings = [...(inventory?.findings || [])];
273 for (const item of inventory?.firewall?.findings || []) if (!findings.some((f) => f.code === item.code && JSON.stringify(f.params) === JSON.stringify(item.params))) findings.push(item);
274 byId("findingCounts").replaceChildren(...["error", "warning", "recommendation"].map((level) => badge(`${levelText(level)}: ${findings.filter((f) => f.level === level).length}`, level === "error" ? "bad" : level === "warning" ? "warn" : "")));
275 fill("findingList", findings.map((item) => {
276 const [reason, fix] = findingText(item);
277 const element = row(reason, item.code, [badge(levelText(item.level), item.level === "error" ? "bad" : item.level === "warning" ? "warn" : "")]);
278 element.classList.add("finding"); element.firstChild.append(node("span", "fix", fix)); return element;
279 }), t("Bez nalezených problémů."));
281function renderAll() { renderInterfaces(); renderRoutes(); renderFirewall(); renderFindings(); syncControls(); }
282function verdictText(verdict) {
283 switch (verdict) {
284 case "nothing-listens": return t("Na portu nic nenaslouchá. Spusťte službu nebo ověřte její port.");
285 case "only-local": return t("Služba naslouchá jen místně. Změňte její naslouchající adresu, má-li být dostupná ze sítě.");
286 case "firewall-blocks": return t("Firewall port blokuje. Navrhněte povolení ze správného zdroje.");
287 case "firewall-unknown": return t("Pravidla firewallu nelze spolehlivě vyhodnotit. Zkontrolujte je s oprávněním root.");
288 case "open-on-server": return t("Služba naslouchá a server port povoluje. Pokud spojení nefunguje, ověřte cestu k serveru.");
289 default: return t("Dostupnost portu se nepodařilo určit.");
290 }
292function firewallAnswer(answer) {
293 switch (answer) {
294 case "no-firewall": return t("Bez firewallu"); case "blocked": return t("Blokováno pravidlem"); case "allowed": return t("Povoleno pravidlem");
295 case "default-blocks": return t("Blokováno výchozí zásadou"); case "default-allows": return t("Povoleno výchozí zásadou"); default: return t("Firewall nezjištěn");
296 }
298function renderDiagnosis(result) {
299 const rows = [];
300 const okBadge = (ok) => badge(ok === true ? t("V pořádku") : ok === false ? t("Selhalo") : t("Nezjištěno"), ok === true ? "good" : "warn");
301 for (const g of result.gateway || []) rows.push(row(t("Brána IPv{family}", g), `${g.gateway || "—"} · ${g.device || "—"}`, [okBadge(g.ok)]));
302 for (const d of result.dns || []) rows.push(row(d.name, t("Odpověď: {answer} · {seconds} s", { answer: Array.isArray(d.answer) ? d.answer.join(", ") : d.answer || "—", seconds: d.seconds ?? "—" }), [okBadge(d.ok)]));
303 for (const item of result.internet || []) rows.push(row(t("Internet IPv{family}", item), typeof item.result === "object" ? JSON.stringify(item.result) : String(item.result ?? "—")));
304 if (result.port) {
305 const p = result.port;
306 rows.push(row(t("Port {port}/{protocol}", p), verdictText(p.verdict)));
307 rows.push(row(t("Vyhodnocení firewallu"), firewallAnswer(p.firewall)));
308 rows.push(row(t("Naslouchání a pravidla"), JSON.stringify({ listeners: p.listeners, rules: p.rules, fromHere: p.fromHere }, null, 2)));
309 rows.push(row(t("Cesta z internetu"), t("Ze serveru nelze ověřit celou cestu z internetu: router, NAT, cloudový firewall ani nadřazená ACL.")));
310 }
311 fill("diagnosticResults", rows);
313function splitList(value) { return value.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean); }
314async function diagnose(portCheck = false) {
315 if (!connected() || !inventory) return;
316 const own = generation, request = ++diagnosticRequest;
317 const port = Number(byId("diagnosticPort").value);
318 if (portCheck && (!Number.isInteger(port) || port < 1 || port > 65535)) { setStatus(t("Zadejte port od 1 do 65535."), true); return; }
319 diagnosticBusy = true; syncControls(); setStatus(t("Kontroluji spojení…"));
320 try {
321 const result = await api.network.diagnose({ sessionId, ...auth(), names: splitList(byId("diagnosticNames").value), ...(portCheck ? { port, protocol: byId("diagnosticProtocol").value } : {}) });
322 if (own !== generation || request !== diagnosticRequest) return;
323 renderDiagnosis(result); setStatus(t("Kontrola dokončena."));
324 } catch (error) { if (own === generation && request === diagnosticRequest) setStatus(errorText(error), true); }
325 finally { if (own === generation && request === diagnosticRequest) { diagnosticBusy = false; syncControls(); } }
328function invalidatePlan() {
329 revision += 1; planRequest += 1; plan = null; planning = false;
330 byId("planPanel").hidden = true; byId("applyPlan").disabled = true;
332function interfacePayload() {
333 if (!selected) throw new Error(t("Vyberte rozhraní."));
334 const data = { interface: selected.name, dns: splitList(byId("interfaceDns").value), search: splitList(byId("interfaceSearch").value), mtu: byId("interfaceMtu").value === "" ? null : Number(byId("interfaceMtu").value) };
335 if (data.mtu !== null && (!Number.isInteger(data.mtu) || data.mtu < 68 || data.mtu > 65535)) throw new Error(t("MTU musí být celé číslo od 68 do 65535."));
336 for (const family of [4, 6]) {
337 const method = byId(`ipv${family}Method`).value;
338 if (method === "unknown" || !method) throw new Error(t("Vyberte metodu pro IPv4 i IPv6. Nezjištěnou metodu nelze bezpečně přepsat."));
339 const addresses = method === "static" ? splitList(byId(`ipv${family}Addresses`).value) : [];
340 if (method === "static" && !addresses.length) throw new Error(t("Pro statickou konfiguraci zadejte alespoň jednu adresu s prefixem."));
341 data[`ipv${family}`] = { method, addresses, gateway: method === "static" ? byId(`gateway${family}`).value.trim() : "" };
342 }
343 return data;
345function rulePayload() {
346 const protocol = byId("ruleProtocol").value;
347 const ports = splitList(byId("rulePorts").value);
348 if (ports.length && !["tcp", "udp"].includes(protocol)) throw new Error(t("Porty lze zadat jen pro TCP nebo UDP."));
349 for (const value of ports) {
350 const parts = value.split(":").map(Number);
351 if (!/^\d+(?::\d+)?$/.test(value) || parts.some((p) => p < 1 || p > 65535) || (parts.length === 2 && parts[0] > parts[1])) throw new Error(t("Zadejte porty 1–65535 nebo rozsah ve tvaru 1000:2000."));
352 }
353 const family = byId("ruleFamily").value;
354 if ((protocol === "icmp" && family === "6") || (protocol === "ipv6-icmp" && family === "4")) throw new Error(t("Protokol ICMP musí odpovídat rodině adres."));
355 const zone = byId("ruleZone").value;
356 return { backend: editableBackend(), zone: zone || undefined, add: [{ direction: byId("ruleDirection").value, action: byId("ruleAction").value, family, protocol, ports, source: byId("ruleSource").value.trim(), destination: byId("ruleDestination").value.trim(), interface: byId("ruleInterface").value, comment: byId("ruleComment").value.trim(), zone }] };
358function policyPayload() {
359 const policy = {};
360 for (const [key, id] of [["input", "policyInput"], ["output", "policyOutput"], ["forward", "policyForward"]]) if (byId(id).value) policy[key] = byId(id).value;
361 const enable = byId("firewallEnable").value;
362 if (enable === "true" && (inventory?.firewall?.backends || []).filter((b) => b.active).length > 1) throw new Error(t("Nejprve vyřešte souběh více firewallů."));
363 if (!Object.keys(policy).length && !enable) throw new Error(t("Vyberte změnu zásady nebo stavu firewallu."));
364 return { backend: editableBackend(), policy, ...(enable ? { enable: enable === "true" } : {}), zone: byId("ruleZone").value || undefined };
366function profilePayload() {
367 const kind = byId("profileKind").value, source = byId("profileSource").value.trim();
368 const wanted = { web: [80, 443], proxy: [80, 443], database: [3306, 5432, 6379, 27017, 1433, 1521], router: [53, 67, 68, 546, 547, 179], proxmox: [8006, 3128, 5900, 5901, 5902, 5405, 5406, 5407] }[kind] || [];
369 if (kind === "database" && !source) throw new Error(t("Pro databázový profil vyplňte povolený zdroj (adresu nebo síť)."));
370 const sshPort = Number(inventory?.ssh?.serverPort);
371 if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535) throw new Error(t("SSH port této relace není známý. Profil nelze bezpečně navrhnout."));
372 const add = [], seen = new Set(), zone = byId("ruleZone").value;
373 const comment = kind === "web" ? t("Profil: Webserver") : kind === "proxy" ? t("Profil: Reverse proxy") : kind === "database" ? t("Profil: Databázový server") : kind === "router" ? t("Profil: Router") : t("Profil: Proxmox host");
374 const append = (port, protocol, allowedSource) => {
375 const key = `${port}/${protocol}/${allowedSource}`;
376 if (seen.has(key)) return;
377 seen.add(key); add.push({ direction: "in", action: "accept", family: "both", protocol, ports: [String(port)], source: allowedSource, destination: "", interface: "", comment, zone });
378 };
379 append(sshPort, "tcp", inventory?.ssh?.clientAddress || "");
380 for (const item of inventory?.listening || []) {
381 const port = Number(item.port), protocol = String(item.protocol).startsWith("tcp") ? "tcp" : String(item.protocol).startsWith("udp") ? "udp" : "";
382 // Loopback-only databases must not be exposed merely by choosing a profile.
383 const local = /^(?:127\.|::1$|\[::1\]$)/.test(String(item.address));
384 if (wanted.includes(port) && protocol && !local) append(port, protocol, source);
385 }
386 return { backend: editableBackend(), zone: zone || undefined, add, remove: [] };
388async function requestPlan(kind, supplied) {
389 if (!connected() || !inventory || busy || changing()) return;
390 if (kind === "firewall" && (!editableBackend() || inventory.firewall?.readable !== true)) return;
391 invalidatePlan();
392 const own = generation, startedRevision = revision, request = ++planRequest;
393 try {
394 const payload = supplied || (kind === "interface" ? interfacePayload() : rulePayload());
395 planning = true; syncControls(); setStatus(t("Připravuji návrh…"));
396 const result = kind === "interface" ? await api.network.planInterface({ sessionId, ...payload, ...auth() }) : await api.network.planFirewall({ sessionId, add: [], remove: [], ...payload, ...auth() });
397 if (own !== generation || startedRevision !== revision || request !== planRequest) return;
398 if (!result?.planId) throw new Error(t("Server nevrátil platný návrh."));
399 plan = { ...result, sessionId, revision, generation, server: api.sessions.displayName(selectedSession()) };
400 showPlan(); setStatus(t("Návrh je připraven. Zkontrolujte rozdíly a rizika."));
401 } catch (error) { if (own === generation && startedRevision === revision && request === planRequest) setStatus(errorText(error), true); }
402 finally { if (own === generation && request === planRequest) { planning = false; syncControls(); } }
404function requestFirewallPayload(make) {
405 try { void requestPlan("firewall", make()); } catch (error) { invalidatePlan(); syncControls(); setStatus(errorText(error), true); }
408// Small LCS, with common edges trimmed and a bounded fallback for very large files.
409function lineDiff(before, after) {
410 const a = String(before ?? "").split("\n"), b = String(after ?? "").split("\n");
411 let start = 0, endA = a.length, endB = b.length;
412 while (start < endA && start < endB && a[start] === b[start]) start += 1;
413 while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA -= 1; endB -= 1; }
414 const lines = a.slice(Math.max(0, start - 3), start).map((line) => ["", line]);
415 const x = a.slice(start, endA), y = b.slice(start, endB);
416 if (x.length * y.length > 4_000_000) {
417 x.forEach((line) => lines.push(["del", line])); y.forEach((line) => lines.push(["add", line]));
418 } else {
419 const table = Array.from({ length: x.length + 1 }, () => new Uint32Array(y.length + 1));
420 for (let i = x.length - 1; i >= 0; i -= 1) for (let j = y.length - 1; j >= 0; j -= 1) table[i][j] = x[i] === y[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
421 let i = 0, j = 0;
422 while (i < x.length || j < y.length) {
423 if (i < x.length && j < y.length && x[i] === y[j]) { lines.push(["", x[i]]); i += 1; j += 1; }
424 else if (i < x.length && (j === y.length || table[i + 1][j] >= table[i][j + 1])) { lines.push(["del", x[i]]); i += 1; }
425 else { lines.push(["add", y[j]]); j += 1; }
426 }
427 }
428 return [...lines, ...a.slice(endA, endA + 3).map((line) => ["", line])];
430function riskText(risk) {
431 const p = { ...(risk.params || {}), ...risk };
432 switch (risk.code) {
433 case "ssh-interface": return t("Měníte rozhraní aktuálního SSH spojení. Spojení může vypadnout.");
434 case "ssh-address-changes": return t("SSH adresa se změní z {from} na {to}. Nové spojení ověříme na nové adrese.", p);
435 case "ssh-address-removed": return t("SSH adresa {from} bude odebrána. Připravte si jinou cestu ke správě.", p);
436 case "bridge-carries-guests": return t("Most přenáší síť virtuálních strojů nebo kontejnerů. Změna může odpojit i je.");
437 case "ipv6-depends-on-router": return t("IPv6 závisí na oznámeních směrovače. DHCPv6 samo výchozí bránu nezajistí.");
438 case "several-firewalls": return t("Běží více firewallů. Jejich pravidla se mohou ovlivňovat.");
439 case "rule-cuts-ssh": return t("Nové pravidlo může přerušit SSH na portu {port}.", p);
440 case "removes-ssh-rule": return t("Odstraňujete pravidlo pro SSH na portu {port}.", p);
441 case "policy-cuts-ssh": return t("Výchozí zásada může zablokovat SSH na portu {port}.", p);
442 case "nft-file-rewritten": return t("Soubor {path} bude přepsán. Zkontrolujte celý rozdíl i vlastní pravidla.", p);
443 case "default-route-options-lost": return t("Výchozí trasa má vlastní volby (metrika, tabulka). Tento návrh je nezachová.");
444 case "runtime-only": return t("Změna platí jen za běhu. Po restartu se použije trvalá konfigurace.");
445 default: return t("Neznámé riziko {code}. Před použitím ověřte jeho význam.", { code: risk.code });
446 }
448function showPlan() {
449 byId("planPanel").hidden = false;
450 byId("planMeta").textContent = t("Server: {server} · síť: {manager} · firewall: {firewall}", { server: plan.server, manager: plan.manager || "—", firewall: plan.firewall || "—" });
451 byId("planFiles").replaceChildren(...(plan.files || []).map((file) => {
452 const block = node("section", "sub-block"); block.append(node("h3", "", file.path));
453 const diff = node("pre", "code diff"), lines = lineDiff(file.before, file.after);
454 if (!lines.some(([kind]) => kind)) diff.textContent = t("Bez rozdílů.");
455 else diff.append(...lines.map(([kind, line]) => node("span", kind, `${kind === "add" ? "+" : kind === "del" ? "-" : " "} ${line}`)));
456 block.append(diff); return block;
457 }));
458 byId("planCommands").textContent = (plan.commands || []).map((c) => typeof c === "string" ? c : JSON.stringify(c)).join("\n") || t("Žádné příkazy.");
459 byId("planRisks").replaceChildren(...(plan.risks || []).map((risk, index) => {
460 const label = node("label", "check");
461 const check = node("input"); check.type = "checkbox"; check.required = true; check.dataset.riskCode = risk.code; check.id = `risk-${index}`;
462 check.addEventListener("change", syncControls); label.append(check, node("span", "", riskText(risk))); return label;
463 }));
464 byId("planSeconds").value = Math.min(3600, Math.max(30, Number(plan.defaultSeconds) || 120));
465 syncControls();
466 byId("planPanel").scrollIntoView({ block: "nearest" });
468function riskChecks() { return [...byId("planRisks").querySelectorAll("input[type=checkbox]")]; }
469function canApply() {
470 const seconds = Number(byId("planSeconds").value);
471 return Boolean(plan && plan.generation === generation && plan.revision === revision && plan.sessionId === sessionId && connected() && !busy && !planning && !changing() && Number.isInteger(seconds) && seconds >= 30 && seconds <= 3600 && riskChecks().length === (plan.risks || []).length && riskChecks().every((check) => check.checked));
474function plainAddress(value) { return String(value || "").trim().split("/")[0].replace(/^\[|\]$/g, ""); }
475async function applyPlan() {
476 if (!canApply()) return;
477 const current = plan;
478 const addressRisk = (current.risks || []).find((r) => r.code === "ssh-address-changes");
479 const address = plainAddress(addressRisk?.to || addressRisk?.params?.to);
480 const tx = {
481 id: null, sessionId: current.sessionId, server: current.server, state: "applying", verified: false,
482 address, oldAddress: inventory?.ssh?.serverAddress || "", deadline: null, operation: 0, action: true,
483 message: t("Používám změnu. Záznam čekající změny se ukládá na serveru."), checking: false
484 };
485 const payload = { planId: current.planId, confirm: true, acceptRisks: [...new Set(riskChecks().map((check) => check.dataset.riskCode))], seconds: Number(byId("planSeconds").value), ...auth(), ...(address ? { expectAddress: address } : {}) };
486 takeSecret("verifyPassword"); takeSecret("verifyPassphrase");
487 byId("transactionLog").textContent = ""; byId("transactionLog").hidden = true;
488 transaction = tx; invalidatePlan(); renderTransaction(); syncControls();
489 try {
490 const result = await api.network.apply(payload);
491 if (!result?.id) throw new Error(t("Server nevrátil ID změny. Znovu načtěte čekající změnu."));
492 tx.id = result.id; tx.state = "pending"; tx.deadline = Date.now() + (Number(result.seconds) || payload.seconds) * 1000;
493 tx.message = t("Ověřuji nové SSH spojení…");
494 } catch (error) {
495 tx.message = t("Výsledek použití není potvrzen: {message}", { message: errorText(error) });
496 // A dropped SSH session can lose the reply after the server has already armed rollback.
497 // Query pending once, only if the original session is still live. Never guess another server.
498 if (connected(tx.sessionId)) {
499 try {
500 const pending = await api.network.pending({ sessionId: tx.sessionId });
501 if (pending?.id) { tx.id = pending.id; tx.state = pending.state || "pending"; }
502 } catch { /* The automatic server rollback remains independent of this client. */ }
503 }
504 if (!tx.id) { tx.state = "unknown"; tx.message += ` ${t("Obnovte SSH relaci a načtěte čekající změnu. Server používá vlastní časovač návratu.")}`; }
505 } finally {
506 tx.action = false;
507 if (!tx.id) tx.untracked = true;
508 renderTransaction(); syncControls();
509 if (tx.id && !terminal(tx)) void verifyTransaction();
510 }
512function startTransaction(details) {
513 transaction = { ...details, verified: false, checking: false, action: false, operation: 0, address: details.address || "", oldAddress: inventory?.ssh?.serverAddress || "", deadline: null, message: t("Obnovuji sledování čekající změny. Zbývající čas určuje server.") };
514 byId("transactionLog").textContent = ""; byId("transactionLog").hidden = true;
515 renderTransaction(); syncControls();
516 if (terminal()) void finishTransaction(transaction, transaction.state);
517 else void verifyTransaction();
519function transactionActive(tx = transaction) { return Boolean(tx && !terminal(tx) && !tx.untracked); }
520function renderTransaction() {
521 const tx = transaction;
522 byId("transactionPanel").hidden = !tx;
523 if (!tx) return;
524 byId("transactionIdentity").textContent = t("Server: {server} · změna: {id}", { server: tx.server || "—", id: tx.id || "—" });
525 notice("transactionState", tx.message || stateText(tx.state));
526 const remaining = tx.deadline ? Math.max(0, Math.ceil((tx.deadline - Date.now()) / 1000)) : null;
527 byId("countdown").textContent = terminal(tx) ? "" : remaining === null ? t("Časový limit spravuje server.") : t("Zbývá {seconds} s", { seconds: remaining });
528 byId("rollbackAdvice").hidden = terminal(tx);
529 byId("confirmChange").hidden = !(tx.verified === true && transactionActive(tx) && (remaining === null || remaining > 0));
530 byId("confirmChange").disabled = tx.action || tx.checking;
531 byId("rollbackChange").hidden = terminal(tx);
532 byId("extendChange").hidden = terminal(tx);
533 for (const id of ["rollbackChange", "extendChange"]) byId(id).disabled = !tx.id || tx.action || !transactionActive(tx);
534 byId("verifyNow").disabled = !tx.id || tx.action || tx.checking;
535 byId("showTransactionLog").disabled = !tx.id;
536 byId("loginFields").hidden = !tx.needsAuth || terminal(tx);
537 notice("reconnectAdvice", tx.state === "rolled-back" ? t("Server obnovil původní stav. Připojte se znovu na původní adresu {address} a načtěte údaje. Záznam změny je níže.", { address: tx.oldAddress || t("z původního SSH profilu") }) : remaining === 0 && !terminal(tx) ? t("Čas na potvrzení vypršel. Server automaticky obnovuje původní stav; čekáme na ověření výsledku. Připojte se na původní adresu {address} a zkontrolujte záznam.", { address: tx.oldAddress || t("z původního SSH profilu") }) : "");
539function needLogin(result, tx, preserveVerification = false) {
540 const message = String(result?.message || result?.error || "");
541 if (result?.code === "PASSWORD_REQUIRED" || /PASSWORD_REQUIRED|passphrase|password required|requires.*password/i.test(message)) {
542 tx.needsAuth = true; if (!preserveVerification) tx.verified = false;
543 tx.message = /passphrase/i.test(message) ? t("Pro nové SSH spojení zadejte heslo k SSH klíči.") : t("Pro nové SSH spojení zadejte přihlašovací heslo.");
544 return true;
545 }
546 return false;
548async function verifyTransaction(manual = false) {
549 const tx = transaction;
550 if (!transactionActive(tx) || !tx.id || tx.checking || tx.action || (tx.verified && !manual)) return;
551 const operation = tx.operation;
552 tx.checking = true; renderTransaction();
553 try {
554 const result = await api.network.verify({ id: tx.id, address: tx.address || undefined, ...(manual ? credentials() : {}) });
555 if (transaction !== tx || operation !== tx.operation || terminal(tx)) return;
556 if (result.state === "rolled-back" || result.state === "confirmed") { await finishTransaction(tx, result.state); return; }
557 if (result.state) tx.state = result.state;
558 if (needLogin(result, tx)) return;
559 tx.verified = result.verified === true && (!tx.deadline || tx.deadline > Date.now());
560 if (tx.verified) {
561 tx.needsAuth = false; tx.message = t("Nové SSH spojení je ověřené. Nyní můžete změnu potvrdit.");
562 } else tx.message = result.error || result.message ? t("Spojení zatím není ověřené: {message}", { message: result.message || result.error }) : result.reachable ? t("Server odpovídá, nové SSH spojení zatím není ověřené.") : t("Server zatím není dostupný. Ověření se opakuje každé 3 sekundy.");
563 } catch (error) {
564 if (transaction !== tx || operation !== tx.operation || terminal(tx)) return;
565 tx.verified = false;
566 if (!needLogin({ message: errorText(error) }, tx)) tx.message = t("Ověření spojení zatím selhává: {message}", { message: errorText(error) });
567 } finally { if (transaction === tx && operation === tx.operation) { tx.checking = false; renderTransaction(); } }
569async function finishTransaction(tx, state) {
570 if (transaction !== tx) return;
571 tx.state = state; tx.verified = false; tx.needsAuth = false; tx.operation += 1; tx.checking = false;
572 takeSecret("verifyPassword"); takeSecret("verifyPassphrase");
573 tx.message = state === "confirmed" ? t("Změna je potvrzená. Automatický návrat byl zrušen.") : t("Server obnovil předchozí konfiguraci.");
574 renderTransaction(); syncControls();
575 await loadTransactionLog(tx);
576 if (state === "confirmed" && transaction === tx && sessionId === tx.sessionId && connected()) await inspect();
578async function confirmChange() {
579 const tx = transaction;
580 if (!tx || tx.verified !== true || !tx.id || !transactionActive(tx) || tx.action || tx.checking || (tx.deadline && tx.deadline <= Date.now())) return;
581 tx.action = true; tx.operation += 1; renderTransaction();
582 try {
583 const result = await api.network.confirm({ id: tx.id, ...credentials() });
584 if (transaction !== tx) return;
585 if (needLogin(result, tx, true)) return;
586 if (result?.state === "rolled-back") { await finishTransaction(tx, "rolled-back"); return; }
587 if (result?.error || (result?.state && result.state !== "confirmed")) {
588 tx.verified = false; tx.message = result.message || result.error || stateText(result.state); return;
589 }
590 await finishTransaction(tx, "confirmed");
591 } catch (error) {
592 if (transaction !== tx) return;
593 if (!needLogin({ message: errorText(error) }, tx, true)) {
594 tx.verified = false; tx.message = t("Potvrzení se nezdařilo: {message}", { message: errorText(error) });
595 }
596 } finally { tx.action = false; renderTransaction(); syncControls(); }
598async function rollbackChange() {
599 const tx = transaction;
600 if (!transactionActive(tx) || !tx.id || tx.action) return;
601 tx.action = true; tx.operation += 1; tx.checking = false; tx.verified = false; renderTransaction();
602 try {
603 const result = await api.network.rollback({ id: tx.id });
604 if (transaction !== tx) return;
605 if (result?.error) throw new Error(result.message || result.error);
606 if (!result?.state || result.state === "rolled-back" || result.state === "confirmed") await finishTransaction(tx, result?.state || "rolled-back");
607 else { tx.state = result.state; tx.message = stateText(result.state); }
608 } catch (error) { if (transaction === tx) tx.message = t("Vrácení zatím není potvrzené: {message}. Automatický časovač na serveru zůstává v platnosti.", { message: errorText(error) }); }
609 finally { tx.action = false; renderTransaction(); syncControls(); }
611async function extendChange() {
612 const tx = transaction;
613 if (!transactionActive(tx) || !tx.id || tx.action) return;
614 tx.action = true; tx.operation += 1; tx.checking = false; renderTransaction();
615 try {
616 const result = await api.network.extend({ id: tx.id, seconds: 120 });
617 if (transaction !== tx) return;
618 if (result?.error) throw new Error(result.message || result.error);
619 if (result?.state === "rolled-back" || result?.state === "confirmed") { await finishTransaction(tx, result.state); return; }
620 // A resumed pending transaction has no remaining-time field in the API.
621 // In that case keep the server-owned deadline unknown rather than invent one.
622 if (Number(result?.seconds) > 0) tx.deadline = Date.now() + Number(result.seconds) * 1000;
623 else if (tx.deadline) tx.deadline = Math.max(Date.now(), tx.deadline) + 120000;
624 tx.expired = false; tx.message = t("Čas na potvrzení byl prodloužen o 2 minuty.");
625 } catch (error) { if (transaction === tx) tx.message = t("Prodloužení se nezdařilo: {message}", { message: errorText(error) }); }
626 finally { tx.action = false; renderTransaction(); }
628async function loadTransactionLog(tx = transaction) {
629 if (!tx?.id) return;
630 const id = tx.id;
631 try {
632 const result = await api.network.transactionLog({ id });
633 if (transaction !== tx || tx.id !== id) return;
634 byId("transactionLog").textContent = typeof result === "string" ? result : JSON.stringify(result, null, 2);
635 } catch (error) { if (transaction === tx) byId("transactionLog").textContent = t("Záznam nelze načíst: {message}", { message: errorText(error) }); }
636 if (transaction === tx) byId("transactionLog").hidden = false;
638function tickTransaction() {
639 const tx = transaction;
640 if (!tx) return;
641 if (transactionActive(tx) && tx.deadline && tx.deadline <= Date.now() && !tx.expired) {
642 tx.expired = true; tx.verified = false; tx.needsAuth = false;
643 void loadTransactionLog(tx); void verifyTransaction();
644 }
645 renderTransaction();
648function switchTab(name, focus = false) {
649 for (const tab of document.querySelectorAll(".tab")) {
650 const active = tab.dataset.tab === name;
651 tab.classList.toggle("active", active); tab.setAttribute("aria-selected", String(active)); tab.tabIndex = active ? 0 : -1;
652 if (active && focus) tab.focus();
653 }
654 for (const panel of document.querySelectorAll(".tab-panel")) panel.hidden = panel.dataset.panel !== name;
656function applyLabels() {
657 if (i18n) { i18n.apply(document); return; }
658 for (const element of document.querySelectorAll("[data-i18n]")) element.textContent = t(element.dataset.i18n);
659 for (const element of document.querySelectorAll("[data-i18n-placeholder]")) element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder));
661function bindEvents() {
662 byId("showPlumbing").addEventListener("change", renderFirewall);
663 byId("closeButton").addEventListener("click", () => api?.close?.());
664 byId("sessionSelect").addEventListener("change", () => { endedSessionId = ""; resetSession(byId("sessionSelect").value); if (sessionId) void inspect(); });
665 byId("refreshButton").addEventListener("click", () => void loadSessions());
666 byId("loadButton").addEventListener("click", () => void inspect());
667 const tabs = [...document.querySelectorAll(".tab")];
668 tabs.forEach((tab, index) => {
669 tab.addEventListener("click", () => switchTab(tab.dataset.tab));
670 tab.addEventListener("keydown", (event) => {
671 const next = event.key === "ArrowRight" ? (index + 1) % tabs.length : event.key === "ArrowLeft" ? (index + tabs.length - 1) % tabs.length : event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : -1;
672 if (next >= 0) { event.preventDefault(); switchTab(tabs[next].dataset.tab, true); }
673 });
674 });
675 for (const id of ["interfaceForm", "firewallForm"]) {
676 byId(id).addEventListener("submit", (event) => event.preventDefault());
677 for (const type of ["input", "change"]) byId(id).addEventListener(type, () => { invalidatePlan(); syncMethods(); syncControls(); });
678 }
679 for (const id of ["profileKind", "profileSource", "firewallBackend"]) {
680 for (const type of ["input", "change"]) byId(id).addEventListener(type, () => { invalidatePlan(); syncControls(); });
681 }
682 byId("planInterface").addEventListener("click", () => void requestPlan("interface"));
683 byId("planRule").addEventListener("click", () => void requestPlan("firewall"));
684 byId("planPolicy").addEventListener("click", () => requestFirewallPayload(policyPayload));
685 byId("planProfile").addEventListener("click", () => requestFirewallPayload(profilePayload));
686 byId("cancelPlan").addEventListener("click", () => { invalidatePlan(); syncControls(); });
687 byId("planSeconds").addEventListener("input", syncControls);
688 byId("applyPlan").addEventListener("click", () => void applyPlan());
689 byId("verifyNow").addEventListener("click", () => void verifyTransaction(true));
690 byId("confirmChange").addEventListener("click", () => void confirmChange());
691 byId("rollbackChange").addEventListener("click", () => void rollbackChange());
692 byId("extendChange").addEventListener("click", () => void extendChange());
693 byId("showTransactionLog").addEventListener("click", () => void loadTransactionLog());
694 byId("diagnosticForm").addEventListener("submit", (event) => event.preventDefault());
695 for (const type of ["input", "change"]) byId("diagnosticForm").addEventListener(type, () => { diagnosticRequest += 1; diagnosticBusy = false; byId("diagnosticResults").replaceChildren(); syncControls(); });
696 byId("runDiagnostics").addEventListener("click", () => void diagnose());
697 byId("checkPort").addEventListener("click", () => void diagnose(true));
699async function init() {
700 bindEvents(); applyLabels(); renderAll();
701 if (!api) { setStatus(t("Rozhraní CH-J není dostupné. Otevřete modul v aplikaci."), true); return; }
702 try { if (i18n && api.ui?.getLanguage) { i18n.setLanguage(await api.ui.getLanguage()); applyLabels(); renderAll(); } } catch { /* Czech is always available. */ }
703 api.ui?.onLanguageChanged?.((language) => { i18n?.setLanguage(language); applyLabels(); renderAll(); if (plan) showPlan(); renderTransaction(); });
704 try { const info = await api.getInfo?.(); byId("pluginVersion").textContent = info ? `${info.name} v${info.version} · ${info.publisher}` : t("Správa sítě a firewallu"); } catch { byId("pluginVersion").textContent = t("Správa sítě a firewallu"); }
705 await loadSessions();
706 setInterval(() => { if (!document.hidden) void loadSessions(); }, 3000);
707 setInterval(() => { void verifyTransaction(); }, 3000);
708 setInterval(tickTransaction, 1000);
710void init().catch((error) => setStatus(errorText(error), true));
SHA-2563de2e8997a59bec2ade19e8b22b8427d547bbe76e666fec6fca34bb4e0e1c02a

CH-J Proprietary Software License 1.14 · Kod sumber disediakan di bawah CH-J Proprietary Software License 1.14. Ketersediaannya tidak mengubah terma lesen atau memberikan hak tambahan.

SHA-256 arkibac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Fail