CH-J Toolsgerenciamento de servidor sobre SSH
E-mail
Fonte publicada

CH-J Tools

Navegue por diretórios e arquivos para uma versão específica do aplicativo.

Transferir fonte ZIP
CH-J / Server Manager 1704 arquivos
0.1.2-beta Adicionado: · Construído:
index.js Adicionado: Data não registada 38,3 KB · 604 linhas
Código Transferir ficheiro
1"use strict";
3// Rsync Manager: a client of the rsync engine in the Core, like the File Manager. All text from servers is inserted as plain text.
4const api = window.chjPlugin;
5const i18n = window.chjPluginI18n.createI18n("cs");
6const t = i18n.t;
7const byId = (id) => document.getElementById(id);
8const LOCAL = "local", NEW = "new";
10let sessions = [];
11let sshProfiles = [];
12let plan = null;
13let activeJob = null;
14let shownItems = [];
15let busy = false;
16// Every change of the job counts, so a preview that finishes later cannot be started for what the form no longer says.
17let revision = 0;
18const choice = { source: LOCAL, target: "" };
20const MODE_INFO = {
21 copy: "Přenese nové a změněné soubory. Co je jen v cíli, zůstane. Nejbezpečnější režim.",
22 mirror: "Cíl bude přesně podle zdroje. Soubory, které ve zdroji nejsou, se z cíle smažou, a jen po potvrzení přesného počtu.",
23 versioned: "Přepsané a odstraněné soubory se přesunou do .chj-versions/<čas>, takže je lze obnovit.",
24 snapshot: "Každý běh vytvoří novou úplnou složku s časem. Nezměněné soubory sdílí místo na disku přes pevné odkazy. Odkaz latest ukazuje na poslední.",
25 compare: "Jen ukáže rozdíly mezi složkami. Nic se nepřenese ani nesmaže.",
26};
27const CODE_MESSAGES = {
28 RSYNC_DELETE_NOT_ACCEPTED: "Počet souborů ke smazání je potřeba potvrdit.",
29 RSYNC_PLAN_EXPIRED: "Náhled už neplatí. Spusťte analýzu znovu.",
30};
32// The Core reports in English like every module API; the user reads the state.
33const STATE_MESSAGES = {
34 ok: "Hotovo.", warning: "Hotovo, ale některé soubory ve zdroji během přenosu zmizely.", partial: "Dokončeno jen částečně: některé soubory se nepřenesly. Podrobnosti jsou ve výstupu.",
35 stopped: "Zastaveno: ke smazání bylo víc souborů, než ukázal náhled. Spusťte analýzu znovu.", cancelled: "Zrušeno.",
36};
37function jobMessage(job) {
38 if (job.errorCode === "RSYNC_SOURCE_MISSING") return t("Zdroj už neexistuje, proto se nic nezměnilo.");
39 if (job.errorCode === "RSYNC_SOURCE_EMPTY") return t("Zdroj je prázdný, proto se nic nesmazalo. Spusťte analýzu znovu.");
40 if (job.errorCode === "RSYNC_SOURCE_UNKNOWN") return t("Zdroj se nepodařilo ověřit, proto se nic nezměnilo.");
41 return STATE_MESSAGES[job.state] ? t(STATE_MESSAGES[job.state]) : `${job.message || t("Přenos selhal.")} ${job.state === "failed" && job.output ? t("Podrobnosti jsou ve výstupu.") : ""}`.trim();
44function errorText(error) { return String(error?.message || error).replace(/^Error invoking remote method '[^']*': Error: /, ""); }
45function setStatus(message, error = false) { const node = byId("status"); node.textContent = message; node.classList.toggle("error", error); }
46function setBusy(value) { busy = value; syncControls(); }
47function node(tag, className, text) { const element = document.createElement(tag); if (className) element.className = className; if (text !== undefined) element.textContent = text; return element; }
48function formatBytes(bytes) {
49 const value = Number(bytes) || 0, units = ["B", "KiB", "MiB", "GiB", "TiB"];
50 const power = value > 0 ? Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1) : 0;
51 return `${(value / 1024 ** power).toLocaleString(i18n.language?.() || undefined, { maximumFractionDigits: power ? 1 : 0 })} ${units[power]}`;
53const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
54function fill(list, rows, empty) { list.replaceChildren(...(rows.length ? rows : [node("p", "muted", empty)])); }
55function row(title, detail, actions = []) {
56 const item = node("div", "site-row"), text = node("div");
57 text.append(node("strong", "", title));
58 if (detail) text.append(node("small", "muted", detail));
59 const buttons = node("div", "actions");
60 for (const action of actions) buttons.append(action);
61 item.append(text, buttons);
62 return item;
64function button(label, action, className = "button") { const element = node("button", className, label); element.type = "button"; element.addEventListener("click", action); return element; }
66// --- where things are --------------------------------------------------------------------------------
68async function loadPlaces() {
69 sessions = (await api.sessions.list()).filter((session) => session.state === "connected");
70 try { sshProfiles = await api.rsync.sshProfiles(); } catch { sshProfiles = []; }
71 renderPlaces();
74function placeLabel(value) {
75 if (value === LOCAL) return t("Tento počítač");
76 if (value.startsWith("session:")) { const session = sessions.find((item) => item.sessionId === value.slice(8)); return session ? api.sessions.displayName(session) : value; }
77 if (value.startsWith("profile:")) { const profile = sshProfiles.find((item) => item.id === value.slice(8)); return profile ? `${profile.label} (${profile.username}@${profile.host})` : value; }
78 return "";
81function renderPlaces() {
82 for (const side of ["source", "target"]) {
83 const select = byId(`${side}Place`);
84 // A saved server that got connected in the meantime is still the same place.
85 const saved = choice[side].startsWith("profile:") ? sessions.find((session) => session.profileId === choice[side].slice(8)) : null;
86 if (saved) choice[side] = `session:${saved.sessionId}`;
87 const wanted = choice[side];
88 const options = [new Option(t("Tento počítač"), LOCAL)];
89 for (const session of sessions) options.push(new Option(api.sessions.displayName(session), `session:${session.sessionId}`));
90 const live = new Set(sessions.map((session) => session.profileId));
91 for (const profile of sshProfiles) if (!live.has(profile.id)) options.push(new Option(`${profile.label} (${profile.username}@${profile.host}) · ${t("připojí se")}`, `profile:${profile.id}`));
92 options.push(new Option(t("Nové SSH připojení (heslo nebo klíč)…"), NEW));
93 if (side === "target") options.unshift(new Option(t("Vyberte cíl…"), ""));
94 select.replaceChildren(...options);
95 select.value = [...select.options].some((option) => option.value === wanted) ? wanted : (side === "target" ? "" : LOCAL);
96 if (choice[side] !== select.value) { revision += 1; plan = null; byId("previewBlock").hidden = true; }
97 choice[side] = select.value;
98 }
99 const server = byId("scheduleServer"), current = server.value;
100 server.replaceChildren(...sessions.map((session) => new Option(api.sessions.displayName(session), session.sessionId)));
101 if ([...server.options].some((option) => option.value === current)) server.value = current;
102 if (server.value !== current) clearSchedules();
103 syncControls();
106function endpoint(side) {
107 const value = byId(`${side}Place`).value, path = byId(`${side}Path`).value.trim(), privileged = byId(`${side}Sudo`).checked;
108 if (value === LOCAL) return { kind: "local", path };
109 if (value.startsWith("session:")) {
110 const session = sessions.find((item) => item.sessionId === value.slice(8));
111 const profileId = session?.profileId && !String(session.profileId).startsWith("adhoc:") ? session.profileId : "";
112 return { kind: "ssh", sessionId: value.slice(8), profileId, path, privileged };
113 }
114 if (value.startsWith("profile:")) return { kind: "ssh", profileId: value.slice(8), path, privileged };
115 return { kind: "", path };
118function readOptions() {
119 const on = (id) => byId(id).checked, number = (id) => Number(byId(id).value) || 0, lines = (id) => byId(id).value.split("\n").map((line) => line.trim()).filter(Boolean);
120 return {
121 permissions: on("optPermissions"), times: on("optTimes"), owner: on("optOwner"), group: on("optGroup"), acls: on("optAcls"), xattrs: on("optXattrs"),
122 hardlinks: on("optHardlinks"), devices: on("optDevices"), numericIds: on("optNumericIds"), compress: on("optCompress"), checksum: on("optChecksum"), sizeOnly: on("optSizeOnly"),
123 resume: on("optResume"), inplace: on("optInplace"), oneFileSystem: on("optOneFs"), updateOnly: on("optUpdate"), existingOnly: on("optExisting"), ignoreExisting: on("optIgnoreExisting"),
124 deleteExcluded: on("optDeleteExcluded"), symlinks: byId("optSymlinks").value, bandwidthKiB: number("optBandwidth"), timeoutSeconds: number("optTimeout"),
125 maxSize: byId("optMaxSize").value.trim(), minSize: byId("optMinSize").value.trim(), keepDays: number("optKeepDays"), keepCount: number("optKeepCount"),
126 excludes: lines("optExcludes"), includes: lines("optIncludes"), extra: byId("optExtra").value.split(/\s+/).filter(Boolean),
127 };
130function isBetweenServers() {
131 const source = endpoint("source"), target = endpoint("target");
132 return source.kind === "ssh" && target.kind === "ssh" && (source.sessionId || source.profileId) !== (target.sessionId || target.profileId);
135function readTransport() {
136 if (!isBetweenServers() || byId("transportKind").value !== "direct") return { kind: "relay" };
137 return { kind: "direct", host: byId("directHost").value.trim(), port: Number(byId("directPort").value) || 22, username: byId("directUser").value.trim(), method: byId("directMethod").value, keyPath: byId("directKey").value.trim(), acceptNewHostKey: byId("directAcceptNew").checked };
140function readSpec() {
141 return { mode: byId("mode").value, contentsOnly: byId("contentsOnly").value === "1", source: endpoint("source"), target: endpoint("target"), options: readOptions(), transport: readTransport() };
144function withSecrets(spec) {
145 return { ...spec, sourceSudo: byId("sourceSudoPassword").value, targetSudo: byId("targetSudoPassword").value, directPassword: byId("directPassword").value };
148function writeSpec(spec) {
149 const place = (value) => {
150 if (value?.kind === "local") return LOCAL;
151 if (value?.sessionId && sessions.some((session) => session.sessionId === value.sessionId)) return `session:${value.sessionId}`;
152 if (value?.profileId) {
153 const live = sessions.find((session) => session.profileId === value.profileId);
154 return live ? `session:${live.sessionId}` : `profile:${value.profileId}`;
155 }
156 return "";
157 };
158 for (const side of ["source", "target"]) {
159 choice[side] = place(spec[side]);
160 byId(`${side}Path`).value = spec[side]?.path || "";
161 byId(`${side}Sudo`).checked = spec[side]?.privileged === true;
162 }
163 renderPlaces();
164 byId("mode").value = spec.mode || "copy";
165 byId("contentsOnly").value = spec.contentsOnly === false ? "0" : "1";
166 const options = spec.options || {};
167 const flags = { optPermissions: "permissions", optTimes: "times", optOwner: "owner", optGroup: "group", optAcls: "acls", optXattrs: "xattrs", optHardlinks: "hardlinks", optDevices: "devices", optNumericIds: "numericIds", optCompress: "compress", optChecksum: "checksum", optSizeOnly: "sizeOnly", optResume: "resume", optInplace: "inplace", optOneFs: "oneFileSystem", optUpdate: "updateOnly", optExisting: "existingOnly", optIgnoreExisting: "ignoreExisting", optDeleteExcluded: "deleteExcluded" };
168 for (const [id, name] of Object.entries(flags)) if (name in options) byId(id).checked = options[name] === true;
169 byId("optSymlinks").value = options.symlinks || "preserve";
170 byId("optBandwidth").value = options.bandwidthKiB || 0;
171 byId("optTimeout").value = options.timeoutSeconds || 0;
172 byId("optMaxSize").value = options.maxSize || "";
173 byId("optMinSize").value = options.minSize || "";
174 byId("optKeepDays").value = options.keepDays || 0;
175 byId("optKeepCount").value = options.keepCount || 0;
176 byId("optExcludes").value = (options.excludes || []).join("\n");
177 byId("optIncludes").value = (options.includes || []).join("\n");
178 byId("optExtra").value = (options.extra || []).join(" ");
179 const transport = spec.transport || {};
180 byId("transportKind").value = transport.kind === "direct" ? "direct" : "relay";
181 byId("directHost").value = transport.host || "";
182 byId("directPort").value = transport.port || 22;
183 byId("directUser").value = transport.username || "";
184 byId("directMethod").value = transport.method || "key";
185 byId("directKey").value = transport.keyPath || "";
186 byId("directAcceptNew").checked = transport.acceptNewHostKey === true;
187 invalidatePlan();
188 syncControls();
191function syncControls() {
192 const direct = byId("transportKind").value === "direct";
193 const between = isBetweenServers();
194 byId("transportBlock").hidden = !between;
195 byId("directFields").hidden = !direct;
196 byId("directKeyField").hidden = byId("directMethod").value !== "key";
197 byId("directPasswordField").hidden = byId("directMethod").value !== "password";
198 for (const side of ["source", "target"]) {
199 const remote = byId(`${side}Place`).value !== LOCAL;
200 byId(`${side}Sudo`).closest("label").hidden = !remote;
201 byId(`${side}SudoField`).hidden = !remote || !byId(`${side}Sudo`).checked;
202 }
203 byId("modeInfo").textContent = t(MODE_INFO[byId("mode").value]);
204 const path = byId("sourcePath").value.trim().replace(/\/+$/, "") || "/srv/www", into = byId("targetPath").value.trim().replace(/\/+$/, "") || "/backup";
205 const name = path.split("/").pop();
206 byId("slashInfo").textContent = byId("contentsOnly").value === "1" ? t("Obsah složky {from} se přenese přímo do {to}.", { from: path, to: into }) : t("Složka se přenese celá: vznikne {to}/{name}.", { to: into, name });
207 byId("previewButton").disabled = busy;
208 byId("runButton").disabled = busy || !plan || byId("mode").value === "compare";
209 byId("cancelButton").hidden = !activeJob;
210 byId("saveProfileButton").disabled = busy;
211 byId("scheduleInstall").disabled = busy;
214function invalidatePlan() {
215 revision += 1;
216 plan = null;
217 byId("previewBlock").hidden = true;
218 syncControls();
221// --- connections -------------------------------------------------------------------------------------
223let connectSide = "";
224function openConnect(side) { connectSide = side; byId("connectDialog").hidden = false; byId("connectHost").focus(); }
225function closeConnect() {
226 byId("connectDialog").hidden = true;
227 byId("connectPassword").value = ""; byId("connectPassphrase").value = "";
228 if (byId(`${connectSide}Place`).value === NEW) { byId(`${connectSide}Place`).value = connectSide === "target" ? "" : LOCAL; choice[connectSide] = byId(`${connectSide}Place`).value; }
229 syncControls();
232async function connectNew() {
233 const method = byId("connectMethod").value;
234 const adhoc = { host: byId("connectHost").value.trim(), port: Number(byId("connectPort").value) || 22, username: byId("connectUser").value.trim(), authMethod: method, ...(method === "privateKey" ? { privateKeyPath: byId("connectKey").value.trim() } : {}) };
235 const payload = { adhoc, ...(method === "privateKey" ? { passphrase: byId("connectPassphrase").value } : { password: byId("connectPassword").value }) };
236 try {
237 setStatus(t("Připojuji…"));
238 for (let attempt = 0; attempt < 2; attempt += 1) {
239 const result = await api.rsync.connect(payload);
240 if (result.hostKey) {
241 const changed = result.hostKey.code === "HOST_KEY_MISMATCH";
242 const question = changed ? t("POZOR: host key serveru {host} se změnil. Otisk: {fingerprint}. Pokračujte, jen pokud změnu očekáváte.", { host: adhoc.host, fingerprint: result.hostKey.fingerprint }) : t("Server {host} zatím není známý. Otisk host key: {fingerprint}. Důvěřovat mu?", { host: adhoc.host, fingerprint: result.hostKey.fingerprint });
243 if (!window.confirm(question)) { setStatus(t("Připojení bylo zrušeno."), true); return; }
244 await api.rsync.trustHost({ sessionId: result.sessionId, profileId: "", fingerprint: result.hostKey.fingerprint, ...(changed ? { knownFingerprint: result.hostKey.knownFingerprint, replaceKnown: true } : {}) });
245 payload.sessionId = result.sessionId;
246 continue;
247 }
248 choice[connectSide] = `session:${result.sessionId}`;
249 invalidatePlan();
250 byId("connectDialog").hidden = true;
251 byId("connectPassword").value = ""; byId("connectPassphrase").value = "";
252 await loadPlaces();
253 setStatus(t("Připojeno."));
254 return;
255 }
256 } catch (error) { setStatus(errorText(error), true); }
259// --- preview and run ---------------------------------------------------------------------------------
261function showProgress(job) {
262 const bar = byId("progress"), percent = job?.progress?.percent;
263 bar.hidden = !job || job.state !== "running";
264 bar.classList.toggle("moving", percent === null || percent === undefined);
265 bar.querySelector("i").style.width = typeof percent === "number" ? `${percent}%` : "";
266 const total = job?.itemsTotal || 0;
267 const parts = [];
268 if (typeof percent === "number") parts.push(`${percent} %`);
269 if (job?.progress?.rate) parts.push(job.progress.rate);
270 if (job?.progress?.remaining) parts.push(t("zbývá {time}", { time: job.progress.remaining }));
271 if (total) parts.push(t("{count} položek", { count: total }));
272 byId("progressText").textContent = job?.state === "running" ? parts.join(" · ") : "";
273 byId("output").textContent = job?.output || "—";
276async function follow(id) {
277 activeJob = id; syncControls();
278 for (;;) {
279 const job = await api.rsync.job({ id });
280 showProgress(job);
281 if (job.state !== "running") { activeJob = null; syncControls(); return job; }
282 await sleep(400);
283 }
286function renderItems() {
287 const filter = byId("itemFilter").value.trim().toLowerCase();
288 const rows = shownItems.filter((item) => !filter || item.path.toLowerCase().includes(filter)).slice(0, 1500);
289 const labels = { new: t("nové"), changed: t("změna"), delete: t("smazat"), metadata: t("metadata"), directory: t("složka"), link: t("odkaz") };
290 byId("itemList").replaceChildren(...rows.map((item) => {
291 const line = node("div", item.change);
292 line.append(node("b", "", labels[item.change] || item.change), node("span", "", item.path));
293 if (item.change === "new" || item.change === "changed") line.append(node("small", "", formatBytes(item.size)));
294 return line;
295 }));
298function renderPreview(job) {
299 const summary = job.summary || {};
300 byId("previewBlock").hidden = false;
301 byId("statNew").textContent = summary.new || 0;
302 byId("statChanged").textContent = summary.changed || 0;
303 byId("statDelete").textContent = summary.delete || 0;
304 byId("statMeta").textContent = summary.metadata || 0;
305 byId("statBytes").textContent = formatBytes(summary.bytes);
306 byId("statDeleteLabel").textContent = job.mode === "versioned" ? t("Přesune se do verzí") : t("K odstranění");
307 const warnings = [];
308 if (job.mode === "compare" && summary.delete) warnings.push(t("Počet souborů, které jsou jen v cíli: {count}. Porovnání nic nesmaže.", { count: summary.delete }));
309 if (job.mode === "mirror" && summary.delete) warnings.push(t("Počet souborů, které se z cíle smažou: {count}. Bez zálohy je nelze vrátit.", { count: summary.delete }));
310 if (job.emptySource) warnings.push(t("Zdroj je prázdný. Pokud nejde o záměr, je cesta nejspíš špatně."));
311 if (job.protectedTarget && ["mirror", "versioned"].includes(job.mode)) warnings.push(t("Cíl je systémová nebo domovská složka. Mazání v ní vyžaduje opsání cesty."));
312 byId("previewWarnings").replaceChildren(...warnings.map((text) => node("p", "warning-box", text)));
313 shownItems = job.items || [];
314 byId("itemsNote").textContent = job.itemsTotal > shownItems.length ? t("Zobrazeno prvních {shown} z {total} položek.", { shown: shownItems.length, total: job.itemsTotal }) : "";
315 renderItems();
318async function offerInstall(job) {
319 const sessionId = job.errorDetails?.sessionId;
320 if (!sessionId) return false;
321 if (job.errorDetails.installer === "none") { setStatus(t("Na serveru chybí rsync a není tam podporovaný správce balíčků."), true); return false; }
322 const session = sessions.find((item) => item.sessionId === sessionId);
323 if (!window.confirm(`${session ? api.sessions.displayName(session) : sessionId}\n\n${t("Na tomto serveru chybí rsync. Nainstalovat ho přes správce balíčků serveru?")}`)) return false;
324 const password = endpoint("source").sessionId === sessionId ? byId("sourceSudoPassword").value : byId("targetSudoPassword").value;
325 const entered = password || window.prompt(t("Heslo pro sudo (prázdné pro NOPASSWD)")) || "";
326 setBusy(true); setStatus(t("Instaluji rsync…"));
327 try {
328 const result = await api.rsync.install({ sessionId, sudoPassword: entered, confirm: true });
329 byId("output").textContent = result.output || "—";
330 setStatus(t("rsync byl nainstalován."));
331 return true;
332 } catch (error) { setStatus(errorText(error), true); return false; } finally { setBusy(false); }
335async function preview() {
336 const spec = readSpec();
337 if (!spec.target.kind && spec.transport.kind !== "direct") { setStatus(t("Vyberte cíl."), true); return; }
338 plan = null; byId("previewBlock").hidden = true;
339 const startedRevision = revision;
340 setBusy(true); setStatus(t("Analyzuji rozdíly, nic se nezmění…"));
341 let job;
342 try {
343 const started = await api.rsync.preview(withSecrets(spec));
344 await loadPlaces().catch(() => {});
345 job = await follow(started.jobId);
346 } catch (error) { setBusy(false); setStatus(errorText(error), true); return; }
347 setBusy(false);
348 if (startedRevision !== revision) { setStatus(t("Úloha se mezitím změnila. Spusťte analýzu znovu."), true); return; }
349 if (job.state === "failed" && job.errorCode === "RSYNC_MISSING") { if (await offerInstall(job)) await preview(); return; }
350 if (job.state === "failed" && job.errorCode === "RSYNC_LOCAL_MISSING") { setStatus(t("Na tomto počítači chybí rsync. Nainstalujte ho (macOS: Homebrew, Linux: balíček rsync)."), true); return; }
351 if (!["ok", "warning"].includes(job.state)) { setStatus(jobMessage(job), true); return; }
352 plan = job;
353 renderPreview(job);
354 byId("previewBlock").scrollIntoView?.({ block: "nearest", behavior: "smooth" });
355 const summary = job.summary || {};
356 setStatus(t("Analýza je hotová. Nové: {new}, změněné: {changed}, k odstranění: {delete}.", { new: summary.new || 0, changed: summary.changed || 0, delete: summary.delete || 0 }));
357 syncControls();
360async function run() {
361 if (!plan) return;
362 const summary = plan.summary || {};
363 const payload = { planId: plan.planId, confirm: true };
364 const remove = (summary.delete || 0) > 0 && ["mirror", "versioned"].includes(plan.mode);
365 const where = (side) => `${side.kind === "local" ? t("Tento počítač") : side.sessionId === "direct-target" ? byId("directHost").value : placeLabel(`session:${side.sessionId}`)}: ${side.path}`;
366 const lines = [t("Spustit synchronizaci?"), `${where(plan.source)} → ${where(plan.target)}`, t("Nové: {new}, změněné: {changed}, objem {bytes}.", { new: summary.new || 0, changed: summary.changed || 0, bytes: formatBytes(summary.bytes) })];
367 if (remove) lines.push(plan.mode === "mirror" ? t("Z cíle se trvale smaže souborů: {count}.", { count: summary.delete }) : t("Do verzí se přesune souborů: {count}.", { count: summary.delete }));
368 if (!window.confirm(lines.join("\n"))) return;
369 if (remove) payload.acceptDeletes = summary.delete;
370 if (remove && plan.protectedTarget) {
371 const typed = window.prompt(t("Cíl {path} je systémová nebo domovská složka. Pro pokračování opište přesnou cestu.", { path: plan.target.path }));
372 if (typed === null) return;
373 payload.confirmProtected = typed;
374 }
375 if (remove && plan.emptySource) {
376 if (!window.confirm(t("Zdroj je prázdný, takže se z cíle smaže všechno. Opravdu pokračovat?"))) return;
377 payload.allowEmptySource = true;
378 }
379 setBusy(true); setStatus(t("Synchronizuji…"));
380 let job;
381 try {
382 const started = await api.rsync.run(payload);
383 job = await follow(started.jobId);
384 } catch (error) { setBusy(false); const text = errorText(error); const code = text.match(/RSYNC_[A-Z_]+/)?.[0]; setStatus(CODE_MESSAGES[code] ? t(CODE_MESSAGES[code]) : text, true); return; }
385 setBusy(false);
386 plan = null;
387 syncControls();
388 const stats = job.stats || {};
389 const saved = stats.matchedBytes ? t(" Díky delta přenosu se {bytes} nemuselo přenášet.", { bytes: formatBytes(stats.matchedBytes) }) : "";
390 setStatus(`${jobMessage(job)}${["ok", "warning"].includes(job.state) ? saved : ""}`, !["ok", "warning"].includes(job.state));
391 void renderHistory();
394async function cancelJob() {
395 if (!activeJob) return;
396 try { await api.rsync.cancel({ id: activeJob }); setStatus(t("Ruším přenos…")); } catch (error) { setStatus(errorText(error), true); }
399async function showCommand() {
400 const box = byId("commandPreview");
401 try {
402 const spec = readSpec();
403 const check = JSON.parse(JSON.stringify(spec));
404 for (const side of ["source", "target"]) if (check[side].kind === "ssh" && !check[side].sessionId) check[side].sessionId = "profile";
405 const described = await api.rsync.describe(check);
406 box.textContent = `rsync ${described.arguments.join(" ")}`;
407 box.hidden = false;
408 } catch (error) { setStatus(errorText(error), true); }
411// --- profiles ----------------------------------------------------------------------------------------
413async function saveProfile() {
414 const name = window.prompt(t("Název profilu"));
415 if (!name) return;
416 const every = window.prompt(t("Spouštět automaticky v této aplikaci po kolika minutách? Prázdné = jen ručně. Běží jen, když je aplikace zapnutá."), "");
417 const payload = { name, spec: readSpec() };
418 if (every && every.trim()) {
419 payload.schedule = { kind: "interval", minutes: Number(every) };
420 payload.scheduleInApp = true;
421 if (payload.spec.mode === "mirror") payload.maxDeletes = Number(window.prompt(t("Kolik souborů smí automatický běh nejvýše smazat? Bez dohledu nikdo nevidí náhled."), "0")) || 0;
422 }
423 try { await api.rsync.saveProfile(payload); setStatus(t("Profil byl uložen.")); await renderProfiles(); }
424 catch (error) { setStatus(errorText(error), true); }
427async function renderProfiles() {
428 const list = await api.rsync.profiles();
429 const describe = (side) => side?.kind === "local" ? `${t("Tento počítač")}:${side.path}` : `${sshProfiles.find((item) => item.id === side?.profileId)?.label || side?.host || "SSH"}:${side?.path || ""}`;
430 fill(byId("profileList"), list.map((profile) => row(profile.name, `${describe(profile.spec.source)} → ${describe(profile.spec.target)} · ${t(MODE_NAMES[profile.spec.mode] || "Kopírování")}${profile.scheduleInApp ? ` · ${t("v aplikaci každých {minutes} min", { minutes: profile.schedule?.minutes })}` : ""}`, [
431 button(t("Načíst"), () => { writeSpec(profile.spec); switchTab("sync"); setStatus(t("Profil {name} je načtený. Spusťte analýzu.", { name: profile.name })); }),
432 button(t("Smazat"), async () => { if (window.confirm(t("Smazat profil {name}? Na serverech se nic nezmění.", { name: profile.name }))) { await api.rsync.deleteProfile({ id: profile.id }); await renderProfiles(); } }),
433 ])), t("Zatím žádný uložený profil."));
435const MODE_NAMES = { copy: "Kopírování", mirror: "Zrcadlení", versioned: "Verzovaná záloha", snapshot: "Snapshoty", compare: "Porovnání bez přenosu" };
437// --- schedules on servers ----------------------------------------------------------------------------
439async function installSchedule() {
440 const kind = byId("scheduleKind").value;
441 const schedule = kind === "interval" ? { kind, minutes: Number(byId("scheduleMinutes").value) } : { kind, time: byId("scheduleTime").value, ...(kind === "weekly" ? { day: Number(byId("scheduleDay").value) } : {}) };
442 const spec = readSpec();
443 if (!window.confirm(t("Nainstalovat tuto úlohu na časovač serveru? Poběží i bez této aplikace, pod účtem root."))) return;
444 setBusy(true); setStatus(t("Instaluji úlohu…"));
445 try {
446 await api.rsync.scheduleInstall({ confirm: true, label: byId("scheduleLabel").value, spec, schedule, maxDeletes: Number(byId("scheduleMaxDeletes").value) || 0, sudoPassword: byId("scheduleSudo").value });
447 setStatus(t("Úloha je nainstalovaná."));
448 if (spec.source.sessionId) byId("scheduleServer").value = spec.source.sessionId;
449 await loadSchedules();
450 } catch (error) { setStatus(errorText(error), true); } finally { setBusy(false); }
453// A late answer about another server must not replace the list of the one shown.
454let scheduleRequest = 0;
455function clearSchedules() { scheduleRequest += 1; byId("scheduleList").replaceChildren(); byId("discoveredList").replaceChildren(); byId("scheduleLog").hidden = true; }
457async function loadSchedules() {
458 const sessionId = byId("scheduleServer").value;
459 if (!sessionId) { setStatus(t("Vyberte server."), true); return; }
460 const sudoPassword = byId("scheduleSudo").value;
461 const request = ++scheduleRequest;
462 try {
463 const list = await api.rsync.scheduleList({ sessionId, sudoPassword });
464 if (request !== scheduleRequest || byId("scheduleServer").value !== sessionId) return;
465 fill(byId("scheduleList"), list.map((job) => row(job.label || job.id, `${job.mode || ""} · ${job.cron || ""} · ${job.lastRun ? t("poslední běh {time}, kód {code}", { time: job.lastRun, code: job.lastExit }) : t("ještě neběžela")}`, [
466 button(t("Spustit teď"), async () => { try { await api.rsync.scheduleStart({ sessionId, id: job.id, sudoPassword, confirm: true }); setStatus(t("Úloha byla spuštěna.")); } catch (error) { setStatus(errorText(error), true); } }),
467 button(t("Log"), async () => { try { const log = await api.rsync.scheduleLog({ sessionId, id: job.id, sudoPassword }); const box = byId("scheduleLog"); box.textContent = log.output || "—"; box.hidden = false; } catch (error) { setStatus(errorText(error), true); } }),
468 button(t("Odebrat"), async () => { if (!window.confirm(t("Odebrat úlohu {name} ze serveru včetně jejího logu? Přenesená data zůstanou.", { name: job.label || job.id }))) return; try { await api.rsync.scheduleRemove({ sessionId, id: job.id, sudoPassword, confirm: true }); await loadSchedules(); } catch (error) { setStatus(errorText(error), true); } }),
469 ])), t("Na tomto serveru nejsou žádné naplánované úlohy."));
470 const found = await api.rsync.discover({ sessionId, sudoPassword });
471 if (request !== scheduleRequest || byId("scheduleServer").value !== sessionId) return;
472 const kinds = { cron: "cron", systemd: "systemd", daemon: t("Démon rsync"), running: t("Běží teď"), script: t("Skript") };
473 fill(byId("discoveredList"), found.map((item) => row(`${kinds[item.kind] || item.kind}${item.place === "-" ? "" : ` · ${item.place}`}`, item.detail)), t("Na serveru nebyl nalezen žádný další rsync."));
474 } catch (error) { setStatus(errorText(error), true); }
477// --- backups -----------------------------------------------------------------------------------------
479function targetForVersions() {
480 const target = endpoint("target");
481 return { ...target, sudoPassword: byId("targetSudoPassword").value };
484async function loadVersions() {
485 try {
486 const target = targetForVersions();
487 if (!target.kind || !target.path) { setStatus(t("Vyberte cíl."), true); return; }
488 const result = await api.rsync.versions(target);
489 fill(byId("versionList"), result.versions.map((version) => row(version.name, `${version.kind === "." ? t("Snapshot") : t("Starší verze souborů")}${result.latest === version.name ? ` · ${t("poslední")}` : ""}`, [
490 button(t("Obnovit…"), () => {
491 byId("sourcePath").value = version.path;
492 choice.source = byId("targetPlace").value; renderPlaces();
493 byId("mode").value = "copy"; byId("contentsOnly").value = "1";
494 invalidatePlan(); switchTab("sync");
495 setStatus(t("Připraveno k obnově. Zkontrolujte cíl a spusťte analýzu."));
496 }),
497 ])), t("V cíli nejsou žádné verze ani snapshoty."));
498 } catch (error) { setStatus(errorText(error), true); }
501async function pruneVersions() {
502 const target = targetForVersions();
503 const keepDays = Number(byId("pruneDays").value) || 0, keepCount = Number(byId("pruneCount").value) || 0;
504 if (!window.confirm(t("Odstranit verze starší než {days} dní v {path}? Vždy zůstane nejméně {count} nejnovějších a poslední snapshot.", { days: keepDays, path: target.path, count: keepCount }))) return;
505 try {
506 const result = await api.rsync.pruneVersions({ ...target, keepDays, keepCount, confirm: true });
507 byId("output").textContent = result.output || "—";
508 setStatus(t("Staré verze byly odstraněny."));
509 await loadVersions();
510 } catch (error) { setStatus(errorText(error), true); }
513// --- diagnostics -------------------------------------------------------------------------------------
515async function loadDiagnostics() {
516 const rows = [];
517 const local = await api.rsync.localProbe();
518 if (local.reason === "windows") rows.push(row(t("Tento počítač"), t("Přenosy z disku Windows zatím nejsou podporované. Přenosy mezi servery fungují.")));
519 else if (!local.available) rows.push(row(t("Tento počítač"), t("rsync nebyl nalezen. macOS: Homebrew, Linux: balíček rsync.")));
520 for (const candidate of local.candidates || []) rows.push(row(`${t("Tento počítač")}: ${candidate.path}`, `${candidate.version}${candidate.realRsync ? "" : ` · ${t("omezená náhrada bez ACL a celkového průběhu")}`}`));
521 for (const session of sessions) {
522 try {
523 const probe = await api.rsync.remoteProbe({ sessionId: session.sessionId });
524 const actions = probe.available || probe.installer === "none" ? [] : [button(t("Nainstalovat rsync"), async () => { if (await offerInstall({ errorDetails: { sessionId: session.sessionId, installer: probe.installer } })) await loadDiagnostics(); })];
525 rows.push(row(api.sessions.displayName(session), probe.available ? `${probe.version} · ACL ${probe.acls ? "✓" : "—"} · xattr ${probe.xattrs ? "✓" : "—"} · ${t("plánovač")}: ${probe.scheduler} · ${probe.ssh || ""}` : t("rsync není nainstalovaný."), actions));
526 } catch (error) { rows.push(row(api.sessions.displayName(session), errorText(error))); }
527 }
528 fill(byId("diagList"), rows, t("Žádné údaje."));
531async function renderHistory() {
532 const list = (await api.rsync.history()).slice().reverse().slice(0, 100);
533 fill(byId("historyList"), list.map((entry) => {
534 const stats = entry.stats || {};
535 return row(`${entry.label || `${entry.source?.path || ""} → ${entry.target?.path || ""}`}`, `${entry.finished || ""} · ${entry.state || ""} · ${entry.message || ""}${stats.transferredBytes ? ` · ${formatBytes(stats.transferredBytes)}` : ""}`);
536 }), t("Zatím žádný dokončený přenos."));
539// --- wiring ------------------------------------------------------------------------------------------
541function switchTab(name) {
542 for (const tab of document.querySelectorAll(".tab")) tab.classList.toggle("active", tab.dataset.tab === name);
543 for (const panel of document.querySelectorAll(".tab-panel")) panel.hidden = panel.dataset.panel !== name;
544 if (name === "schedule" && byId("scheduleServer").value) void loadSchedules();
545 if (name === "profiles") void renderProfiles().catch((error) => setStatus(errorText(error), true));
546 if (name === "diag") { void renderHistory().catch(() => {}); void loadDiagnostics().catch((error) => setStatus(errorText(error), true)); }
549for (const tab of document.querySelectorAll(".tab")) tab.addEventListener("click", () => switchTab(tab.dataset.tab));
550for (const side of ["source", "target"]) {
551 byId(`${side}Place`).addEventListener("change", (event) => {
552 invalidatePlan();
553 if (event.target.value === NEW) { openConnect(side); return; }
554 choice[side] = event.target.value;
555 });
556 byId(`${side}Path`).addEventListener("input", invalidatePlan);
557 byId(`${side}Sudo`).addEventListener("change", invalidatePlan);
559document.querySelector("[data-panel=sync]").addEventListener("change", (event) => { if (event.target.closest(".options, #transportBlock") || ["mode", "contentsOnly"].includes(event.target.id)) invalidatePlan(); });
560byId("previewButton").addEventListener("click", () => void preview());
561byId("runButton").addEventListener("click", () => void run());
562byId("cancelButton").addEventListener("click", () => void cancelJob());
563byId("showCommandButton").addEventListener("click", () => void showCommand());
564byId("saveProfileButton").addEventListener("click", () => void saveProfile());
565byId("itemFilter").addEventListener("input", renderItems);
566byId("connectMethod").addEventListener("change", () => { const key = byId("connectMethod").value === "privateKey"; byId("connectKeyField").hidden = !key; byId("connectPassphraseField").hidden = !key; byId("connectPasswordField").hidden = key; });
567byId("connectCancel").addEventListener("click", closeConnect);
568byId("connectGo").addEventListener("click", () => void connectNew());
569byId("scheduleKind").addEventListener("change", () => { const kind = byId("scheduleKind").value; byId("scheduleMinutesField").hidden = kind !== "interval"; byId("scheduleTimeField").hidden = kind === "interval"; byId("scheduleDayField").hidden = kind !== "weekly"; });
570byId("scheduleInstall").addEventListener("click", () => void installSchedule());
571byId("scheduleLoad").addEventListener("click", () => void loadSchedules());
572byId("scheduleServer").addEventListener("change", () => { clearSchedules(); void loadSchedules(); });
573byId("versionsLoad").addEventListener("click", () => void loadVersions());
574byId("pruneButton").addEventListener("click", () => void pruneVersions());
575byId("diagLoad").addEventListener("click", () => void loadDiagnostics());
576byId("historyClear").addEventListener("click", async () => { if (window.confirm(t("Vymazat historii přenosů?"))) { await api.rsync.clearHistory(); await renderHistory(); } });
577byId("closeButton").addEventListener("click", () => api.close());
579function fillStaticChoices() {
580 const minutes = byId("scheduleMinutes"), keep = minutes.value || "30";
581 minutes.replaceChildren(...[5, 10, 15, 20, 30, 60, 120, 180, 240, 360, 720, 1440].map((value) => new Option(value < 60 ? t("každých {count} min", { count: value }) : t("každých {count} h", { count: value / 60 }), String(value))));
582 minutes.value = keep;
583 const day = byId("scheduleDay"), keepDay = day.value || "1";
584 day.replaceChildren(...["Neděle", "Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota"].map((name, index) => new Option(t(name), String(index))));
585 day.value = keepDay;
588async function takeHandoff() {
589 const spec = await api.rsync.takeHandoff();
590 if (spec) { writeSpec(spec); switchTab("sync"); setStatus(t("Úloha z File Manageru je připravená. Upravte ji a spusťte analýzu.")); }
593(async () => {
594 try { i18n.setLanguage(await api.ui.getLanguage()); } catch {}
595 i18n.apply(document);
596 fillStaticChoices();
597 api.ui.onLanguageChanged((language) => { i18n.setLanguage(language); i18n.apply(document); fillStaticChoices(); renderPlaces(); syncControls(); });
598 const info = await api.getInfo(); byId("pluginVersion").textContent = `${info.name} v${info.version} · ${info.publisher}`;
599 await loadPlaces();
600 await takeHandoff();
601 syncControls();
602})().catch((error) => setStatus(errorText(error), true));
604setInterval(() => { if (!document.hidden && byId("connectDialog").hidden) { void loadPlaces().catch(() => {}); void takeHandoff().catch(() => {}); } }, 3000);
SHA-25647704a0ee0521282ccab9caf3ee97fbffb86734ccf8d8d28d3640a31f6468d4a

CH-J Proprietary Software License 1.14 · A fonte é fornecida sob o CH-J Proprietary Software License 1.14. A sua disponibilidade não altera os termos licença nem concede direitos adicionais.

Ficheiro SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Ficheiros