インストール時に毎回必要ライブラリがインストールされていたのを修正。また、大きなデータをドライランしたときに中断するボタンがなかったので中断ボタンも装備。また進捗率を表示したり、Copyモードの時のデフォルトオプションを設定しました。
アップデートする時は一度アンインストールしてからのほうが安全かなと思います。

LXDコンテナにインストール
#!/bin/bash
set -e
INSTALL_DIR="/opt/rsyncgui"
PORT=3326
SERVICE_NAME="rsyncgui"
echo "=== rsyncGUI Installer ==="
# Detect package manager
if command -v apt-get &>/dev/null; then
PKG="apt"
elif command -v yum &>/dev/null; then
PKG="yum"
elif command -v dnf &>/dev/null; then
PKG="dnf"
elif command -v apk &>/dev/null; then
PKG="apk"
else
echo "Unsupported package manager. Install python3, rsync, sshpass manually."
exit 1
fi
echo "[1/6] Checking dependencies..."
# 修正点: python3 / rsync / sshpass がすでに入っていれば apt-get 等を
# 一切呼ばない。apt update + 複数パッケージinstallは(特にコンテナ内で)
# 数十秒〜かかることがあり、再インストール時の遅さの主因だったため。
NEED_PKGS=()
command -v python3 &>/dev/null || NEED_PKGS+=("python3")
command -v rsync &>/dev/null || NEED_PKGS+=("rsync")
command -v sshpass &>/dev/null || NEED_PKGS+=("sshpass")
# ssh(クライアント)が無いケースは稀だが念のため確認
command -v ssh &>/dev/null || NEED_PKGS+=("__ssh_client__")
if [ "${#NEED_PKGS[@]}" -eq 0 ]; then
echo " -> python3, rsync, sshpass, ssh はインストール済みです。スキップします。"
else
echo " -> 不足しているパッケージをインストールします: ${NEED_PKGS[*]}"
case $PKG in
apt)
PKGS_TO_INSTALL=()
for p in "${NEED_PKGS[@]}"; do
if [ "$p" = "__ssh_client__" ]; then
PKGS_TO_INSTALL+=("openssh-client")
else
PKGS_TO_INSTALL+=("$p")
fi
done
apt-get update -qq
apt-get install -y -qq "${PKGS_TO_INSTALL[@]}"
;;
yum)
PKGS_TO_INSTALL=()
for p in "${NEED_PKGS[@]}"; do
if [ "$p" = "__ssh_client__" ]; then
PKGS_TO_INSTALL+=("openssh-clients")
else
PKGS_TO_INSTALL+=("$p")
fi
done
yum install -y -q "${PKGS_TO_INSTALL[@]}"
;;
dnf)
PKGS_TO_INSTALL=()
for p in "${NEED_PKGS[@]}"; do
if [ "$p" = "__ssh_client__" ]; then
PKGS_TO_INSTALL+=("openssh-clients")
else
PKGS_TO_INSTALL+=("$p")
fi
done
dnf install -y -q "${PKGS_TO_INSTALL[@]}"
;;
apk)
PKGS_TO_INSTALL=()
for p in "${NEED_PKGS[@]}"; do
if [ "$p" = "__ssh_client__" ]; then
PKGS_TO_INSTALL+=("openssh-client")
else
PKGS_TO_INSTALL+=("$p")
fi
done
apk add --no-cache "${PKGS_TO_INSTALL[@]}"
;;
esac
fi
echo "[2/6] Creating install directory..."
mkdir -p "$INSTALL_DIR/public"
echo "[3/6] Writing server..."
cat > "$INSTALL_DIR/server.py" << 'PYEOF'
#!/usr/bin/env python3
import http.server
import json
import os
import re
import subprocess
import threading
import time
import signal
import sys
import tempfile
from urllib.parse import urlparse, parse_qs
PORT = 3326
PUBLIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "public")
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
CRON_TAG = "# rsyncgui-managed"
INTERVAL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "intervals.json")
MIME = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
}
processes = {}
interval_processes = {}
# rsync --info=progress2 の出力行例:
# " 1,234,567 43% 12.34MB/s 0:00:05 (xfr#3, to-chk=10/20)"
# 先頭の転送済みバイト数と % を抜き出す。
PROGRESS2_RE = re.compile(
r"^\s*([\d,]+)\s+(\d{1,3})%\s+([\d.]+\S*B/s)\s+(\S+)"
)
def load_config():
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
return json.load(f)
return {"pairs": [], "schedules": []}
def save_config(cfg):
with open(CONFIG_FILE, "w") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def build_rsync_cmd(job):
"""
修正点:
- mode による -avz のベタ書きを廃止。
mode (copy/sync) はUI上のプリセット切り替えラベルとして使うのみで、
実際のコマンド生成には一切関与させない。-r を含むすべてのフラグは
完全に options(チェックボックスの状態)に委任する(二重適用防止、
かつ「チェックボックスを切っているのに実際は再帰される」という
表示と実態のズレを防ぐため)。
- --partial のショートオプション (-P) を削除。
-P は rsync では --partial --progress の複合であり、
--partial 単独のショートオプションは存在しない。
- --backup-dir の値があれば --backup と合わせて渡す。
- 進捗表示のため --info=progress2 を常に付与する。
(--progress チェックボックスの有無に関わらず、進捗バー計算に
必要な情報を得るために追加。ユーザーが --progress を別途
付けていても rsync 側で重複適用にはならない。)
"""
args = []
# mode 自体は現状コマンド生成には使わない(UI側のプリセット切り替え用)。
# 将来の拡張(cron表示など)で参照する可能性があるため job からは取得しておく。
_mode = job.get("mode", "sync") # noqa: F841
options = job.get("options", {})
for key, val in options.items():
if key == "backup-dir":
# backup-dir は値付きオプション。空文字なら無視。
if val and str(val).strip():
args.append(f"--backup-dir={val}")
elif val is True:
args.append(f"--{key}")
elif val is not False and val != "" and val is not None:
args.append(f"--{key}={val}")
if job.get("dryRun") or job.get("dry_run"):
args.append("--dry-run")
# 進捗バー計算用。stdout を機械的にパースするため、
# 人間向けの --progress とは別に常時付与する。
args.append("--info=progress2")
# progress2 はファイル総数の事前カウントがあるとより安定するため no-inc-recursive を付与。
args.append("--no-inc-recursive")
ssh_cfg = job.get("ssh")
if ssh_cfg:
ssh_parts = ["ssh"]
if ssh_cfg.get("port") and ssh_cfg["port"] != "22":
ssh_parts.append(f"-p {ssh_cfg['port']}")
if ssh_cfg.get("key"):
ssh_parts.append(f"-i {ssh_cfg['key']}")
ssh_parts.append("-o StrictHostKeyChecking=no")
args.append("-e")
args.append(" ".join(ssh_parts))
exclude = job.get("exclude", "")
for line in exclude.split("\n"):
line = line.strip()
if line:
args.append(f"--exclude={line}")
source = job.get("source", "")
target = job.get("target", "")
if ssh_cfg and ssh_cfg.get("host"):
user_prefix = f"{ssh_cfg['user']}@" if ssh_cfg.get("user") else ""
if ":" not in target:
target = f"{user_prefix}{ssh_cfg['host']}:{target}"
args.append(source)
args.append(target)
use_sshpass = ssh_cfg and ssh_cfg.get("password") and ssh_cfg.get("host")
password = ssh_cfg.get("password", "") if use_sshpass else ""
return args, use_sshpass, password
def get_crontab():
try:
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
if result.returncode == 0:
return result.stdout
except Exception:
pass
return ""
def set_crontab(content):
proc = subprocess.run(["crontab", "-"], input=content, text=True, capture_output=True)
return proc.returncode == 0
def schedule_to_cron_time(schedule):
time_str = schedule.get("time", "02:00")
hour, minute = time_str.split(":")
days = schedule.get("days", [])
if not days:
return None
day_str = ",".join(str(d) for d in days)
return f"{minute} {hour} * * {day_str}"
def pair_to_cron_cmd(pair):
job = {
"source": pair.get("source", ""),
"target": pair.get("target", ""),
"mode": pair.get("mode", "sync"),
"options": pair.get("options", {}),
"ssh": pair.get("ssh") if pair.get("sshEnabled") else None,
"exclude": pair.get("exclude", ""),
"dryRun": pair.get("dryRun", False),
}
args, use_sshpass, password = build_rsync_cmd(job)
if use_sshpass:
return f"sshpass -f /tmp/rsyncgui_cron_pw_{pair.get('id', '?')} rsync " + " ".join(args)
return "rsync " + " ".join(args)
def sync_crontab(pairs):
existing = get_crontab()
lines = [l for l in existing.splitlines() if CRON_TAG not in l]
for pair in pairs:
sched = pair.get("schedule", {})
if not sched.get("enabled"):
continue
sched_mode = sched.get("mode", "time")
if sched_mode == "interval":
continue
time_str = sched.get("time", "02:00")
days = sched.get("days", [])
if not days:
continue
hour, minute = time_str.split(":")
day_str = ",".join(str(d) for d in days)
cron_time = f"{minute} {hour} * * {day_str}"
cmd = pair_to_cron_cmd(pair)
cron_line = f"{cron_time} {cmd} {CRON_TAG} id={pair.get('id', '?')}"
lines.append(cron_line)
new_crontab = "\n".join(lines) + "\n" if lines else ""
return set_crontab(new_crontab)
def load_intervals():
if os.path.exists(INTERVAL_FILE):
with open(INTERVAL_FILE, "r") as f:
return json.load(f)
return []
def save_intervals(intervals):
with open(INTERVAL_FILE, "w") as f:
json.dump(intervals, f, indent=2, ensure_ascii=False)
def parse_progress_line(line):
"""
rsync --info=progress2 の1行から (transferred_bytes, percent, rate, eta) を抽出。
マッチしなければ None を返す。
"""
m = PROGRESS2_RE.match(line)
if not m:
return None
bytes_str, percent_str, rate, eta = m.groups()
try:
transferred = int(bytes_str.replace(",", ""))
percent = int(percent_str)
except ValueError:
return None
return {"transferred": transferred, "percent": percent, "rate": rate, "eta": eta}
def make_job_entry(proc):
return {
"proc": proc,
"log": [],
"done": False,
"exitCode": None,
"progress": {"percent": 0, "transferred": 0, "rate": "", "eta": ""},
"cancelled": False,
}
def stream_output(job_id, proc):
"""
rsync の stdout を読み、progress2 の行は進捗情報として processes[job_id]["progress"]
に格納する(ログにも残すが、進捗専用行は大量に出るためログには出さない)。
"""
for raw_line in proc.stdout:
# progress2 は \r で行を更新するため、\r 区切りでも分割する
for line in raw_line.replace("\r", "\n").split("\n"):
if not line:
continue
progress = parse_progress_line(line)
if progress:
processes[job_id]["progress"] = progress
else:
processes[job_id]["log"].append({"type": "stdout", "text": line})
for line in proc.stderr:
processes[job_id]["log"].append({"type": "stderr", "text": line})
def start_interval_scheduler(pairs):
for pair_id in list(interval_processes.keys()):
if not any(p.get("id") == pair_id for p in pairs):
interval_processes[pair_id]["stop"] = True
for pair in pairs:
sched = pair.get("schedule", {})
if not sched.get("enabled") or sched.get("mode") != "interval":
continue
pair_id = pair.get("id")
if pair_id in interval_processes:
existing = interval_processes[pair_id]
if existing.get("interval") == sched.get("interval") and existing.get("unit") == sched.get("intervalUnit"):
continue
existing["stop"] = True
interval_seconds = sched.get("interval", 60) * {"s": 1, "m": 60, "h": 3600}.get(sched.get("intervalUnit", "s"), 1)
stop_flag = {"stop": False}
interval_processes[pair_id] = {"interval": sched.get("interval"), "unit": sched.get("intervalUnit"), "stop": False, "thread": None}
def run_interval(pid, secs, pair_data, sf):
while not sf["stop"]:
time.sleep(1)
if sf["stop"]:
break
job = {
"source": pair_data.get("source", ""),
"target": pair_data.get("target", ""),
"mode": pair_data.get("mode", "sync"),
"options": pair_data.get("options", {}),
"ssh": pair_data.get("ssh") if pair_data.get("sshEnabled") else None,
"exclude": pair_data.get("exclude", ""),
"dryRun": pair_data.get("dryRun", False),
}
args, use_sshpass, password = build_rsync_cmd(job)
job_id = f"int_{pid}_{int(time.time() * 1000)}"
pw_file = None
try:
if use_sshpass:
pw_file = tempfile.NamedTemporaryFile(mode='w', delete=False, prefix='rsyncgui_intpw_')
pw_file.write(password)
pw_file.close()
os.chmod(pw_file.name, 0o600)
cmd_list = ["sshpass", "-f", pw_file.name, "rsync"] + args
else:
cmd_list = ["rsync"] + args
print(f"[rsyncgui] interval run: pid={pid} cmd={' '.join(cmd_list)}", flush=True)
proc = subprocess.Popen(
cmd_list,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
preexec_fn=os.setsid,
)
processes[job_id] = make_job_entry(proc)
stream_output(job_id, proc)
proc.wait()
processes[job_id]["done"] = True
processes[job_id]["exitCode"] = proc.returncode
print(f"[rsyncgui] interval done: pid={pid} exit={proc.returncode}", flush=True)
except Exception as e:
processes[job_id]["log"].append({"type": "error", "text": str(e)})
processes[job_id]["done"] = True
processes[job_id]["exitCode"] = -1
print(f"[rsyncgui] interval error: pid={pid} err={e}", flush=True)
finally:
if pw_file and os.path.exists(pw_file.name):
os.unlink(pw_file.name)
for _ in range(secs):
if sf["stop"]:
break
time.sleep(1)
t = threading.Thread(target=run_interval, args=(pair_id, interval_seconds, pair, stop_flag), daemon=True)
t.start()
interval_processes[pair_id]["thread"] = t
class Handler(http.server.BaseHTTPRequestHandler):
# 修正点: クライアントが応答を読まなくなった接続を無期限に保持しないよう
# ソケットにタイムアウトを設定(recv/sendの両方に効く)。
# ThreadingHTTPServer化と合わせて、1接続がハングしても他の接続をブロックせず、
# かつそのハングしたスレッド自体も一定時間で解放されるようにする。
timeout = 30
def log_message(self, format, *args):
pass
def handle_error(self, request, client_address):
# 修正点: タイムアウトやクライアント切断は通常運用で頻発しうる想定内の事象。
# フルスタックトレースをjournalに吐き続けるとログが肥大化するため、
# これらは1行だけ記録し、それ以外の想定外エラーのみ通常通り出力する。
exc_type = sys.exc_info()[0]
if exc_type in (BrokenPipeError, ConnectionResetError, TimeoutError):
print(f"[rsyncgui] connection closed early: {client_address}")
else:
super().handle_error(request, client_address)
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def _json_response(self, data, code=200):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self._cors()
self.send_header("Content-Length", len(body))
self.end_headers()
self.wfile.write(body)
def _file_response(self, filepath):
ext = os.path.splitext(filepath)[1]
mime = MIME.get(ext, "application/octet-stream")
try:
with open(filepath, "rb") as f:
data = f.read()
self.send_response(200)
self.send_header("Content-Type", f"{mime}; charset=utf-8")
self._cors()
self.send_header("Content-Length", len(data))
self.end_headers()
self.wfile.write(data)
except FileNotFoundError:
self.send_response(404)
self.end_headers()
self.wfile.write(b"Not found")
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
params = parse_qs(parsed.query)
if path == "/api/browse":
browse_path = params.get("path", ["/"])[0]
self._browse(browse_path)
elif path == "/api/du":
du_path = params.get("path", [""])[0]
self._du(du_path)
elif path.startswith("/api/status/"):
job_id = path.split("/")[-1]
self._status(job_id)
elif path == "/api/config":
self._get_config()
elif path == "/api/cron":
self._get_cron()
else:
if path == "/":
path = "/index.html"
filepath = os.path.join(PUBLIC, path.lstrip("/"))
self._file_response(filepath)
def do_POST(self):
parsed = urlparse(self.path)
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
if parsed.path == "/api/rsync":
self._run_rsync(body)
elif parsed.path == "/api/config":
self._save_config(body)
elif parsed.path == "/api/schedule/save":
self._save_schedule(body)
elif parsed.path.startswith("/api/cancel/"):
job_id = parsed.path.split("/")[-1]
self._cancel(job_id)
else:
self.send_response(404)
self.end_headers()
def _browse(self, browse_path):
browse_path = browse_path or "/"
try:
entries = os.scandir(browse_path)
items = []
for e in entries:
if e.name.startswith("."):
continue
if e.is_dir():
items.append({
"name": e.name,
"type": "dir",
"path": os.path.join(browse_path, e.name),
})
items.sort(key=lambda x: x["name"])
parent = os.path.dirname(browse_path.rstrip("/"))
if not parent:
parent = "/"
self._json_response({"current": browse_path, "parent": parent, "items": items})
except Exception as ex:
self._json_response({"error": str(ex)}, 500)
def _du(self, du_path):
# 修正点: 進捗%の計算は rsync 自身の --info=progress2 を使うため
# サーバー側で事前に容量を計算する処理は持たない。
# ただし参考表示用に総容量を見たい場合に使える軽量エンドポイントとして残す。
if not du_path:
self._json_response({"error": "path is required"}, 400)
return
try:
result = subprocess.run(
["du", "-sb", du_path], capture_output=True, text=True, timeout=30
)
size = 0
if result.returncode == 0 and result.stdout:
size = int(result.stdout.split()[0])
self._json_response({"path": du_path, "bytes": size})
except Exception as ex:
self._json_response({"error": str(ex)}, 500)
def _get_config(self):
cfg = load_config()
self._json_response(cfg)
def _save_config(self, body):
try:
cfg = json.loads(body)
save_config(cfg)
self._json_response({"ok": True})
except Exception as ex:
self._json_response({"error": str(ex)}, 500)
def _save_schedule(self, body):
try:
data = json.loads(body)
pairs = data.get("pairs", [])
sync_crontab(pairs)
start_interval_scheduler(pairs)
cfg = load_config()
cfg["pairs"] = pairs
save_config(cfg)
self._json_response({"ok": True})
except Exception as ex:
self._json_response({"error": str(ex)}, 500)
def _get_cron(self):
content = get_crontab()
lines = [l for l in content.splitlines() if CRON_TAG in l]
cfg = load_config()
interval_entries = []
for pair in cfg.get("pairs", []):
sched = pair.get("schedule", {})
if sched.get("enabled") and sched.get("mode") == "interval":
interval_val = sched.get("interval", 60)
interval_unit = sched.get("intervalUnit", "s")
unit_labels = {"s": "秒", "m": "分", "h": "時間"}
interval_entries.append({
"id": pair.get("id", "?"),
"source": pair.get("source", ""),
"target": pair.get("target", ""),
"interval": interval_val,
"intervalUnit": interval_unit,
"intervalLabel": f"{interval_val}{unit_labels.get(interval_unit, '秒')}ごと"
})
self._json_response({"entries": lines, "intervals": interval_entries})
def _run_rsync(self, body):
try:
job = json.loads(body)
args, use_sshpass, password = build_rsync_cmd(job)
job_id = f"job_{int(time.time() * 1000)}"
cmd = "rsync " + " ".join(args)
if use_sshpass:
cmd = f"sshpass -f /tmp/rsyncgui_pw_{job_id} rsync " + " ".join(args)
self._json_response({"id": job_id, "command": cmd})
def run():
pw_file = None
try:
if use_sshpass:
pw_file = tempfile.NamedTemporaryFile(mode='w', delete=False, prefix='rsyncgui_pw_')
pw_file.write(password)
pw_file.close()
os.chmod(pw_file.name, 0o600)
cmd_list = ["sshpass", "-f", pw_file.name, "rsync"] + args
else:
cmd_list = ["rsync"] + args
# 修正点: 中断ボタンのため、プロセスグループを新規に作成する。
# rsync が ssh 等の子プロセスを起こすことがあるため、
# killpg でグループ全体に SIGTERM を送れるようにしておく。
proc = subprocess.Popen(
cmd_list,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
preexec_fn=os.setsid,
)
processes[job_id] = make_job_entry(proc)
stream_output(job_id, proc)
proc.wait()
processes[job_id]["done"] = True
if processes[job_id].get("cancelled"):
processes[job_id]["exitCode"] = proc.returncode if proc.returncode is not None else -15
else:
processes[job_id]["exitCode"] = proc.returncode
except Exception as e:
processes[job_id]["log"].append({"type": "error", "text": str(e)})
processes[job_id]["done"] = True
processes[job_id]["exitCode"] = -1
finally:
if pw_file and os.path.exists(pw_file.name):
os.unlink(pw_file.name)
threading.Thread(target=run, daemon=True).start()
except Exception as ex:
self._json_response({"error": str(ex)}, 500)
def _cancel(self, job_id):
p = processes.get(job_id)
if not p:
self._json_response({"error": "not found"}, 404)
return
proc = p.get("proc")
if not proc or p.get("done"):
self._json_response({"ok": False, "error": "already finished"})
return
try:
p["cancelled"] = True
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
# 少し待って、まだ生きていれば強制終了
def force_kill_if_needed():
time.sleep(3)
try:
if proc.poll() is None:
os.killpg(pgid, signal.SIGKILL)
except Exception:
pass
threading.Thread(target=force_kill_if_needed, daemon=True).start()
p["log"].append({"type": "error", "text": "(ユーザー操作により中断されました)"})
self._json_response({"ok": True})
except ProcessLookupError:
self._json_response({"ok": False, "error": "process already exited"})
except Exception as ex:
self._json_response({"error": str(ex)}, 500)
def _status(self, job_id):
p = processes.get(job_id)
if not p:
self._json_response({"error": "not found"}, 404)
return
log = p["log"][:]
p["log"] = []
self._json_response({
"done": p["done"],
"exitCode": p["exitCode"],
"log": log,
"progress": p.get("progress", {"percent": 0, "transferred": 0, "rate": "", "eta": ""}),
"cancelled": p.get("cancelled", False),
})
def main():
# 修正点: HTTPServer(同期・シングルスレッド)から ThreadingHTTPServer に変更。
# 元の実装ではクライアント接続が1つハングするだけで他の全リクエストが
# ブロックされ、サービス再起動時もSIGTERMで終了できずSIGKILLされていた。
server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
server.daemon_threads = True
print(f"rsyncGUI running at http://localhost:{PORT}")
cfg = load_config()
start_interval_scheduler(cfg.get("pairs", []))
def shutdown(sig, frame):
print("\nShutting down...")
for pid in list(interval_processes.keys()):
interval_processes[pid]["stop"] = True
server.shutdown()
sys.exit(0)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
server.serve_forever()
if __name__ == "__main__":
main()
PYEOF
echo "[4/6] Writing frontend..."
cat > "$INSTALL_DIR/public/index.html" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rsyncGUI</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CjxyZWN0IHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgcng9IjE0IiBmaWxsPSIjMGYxMTE3Ii8+CjxwYXRoIGQ9Ik0xNCAzMiBINDIgTTQyIDMyIEwzMCAyMCBNNDIgMzIgTDMwIDQ0IiBzdHJva2U9IiM1YjhhZjUiIHN0cm9rZS13aWR0aD0iNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBmaWxsPSJub25lIi8+Cjwvc3ZnPgo=">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #232733;
--border: #2e3345;
--text: #e1e4ed;
--text2: #8b90a0;
--accent: #5b8af5;
--accent2: #4a6fd8;
--green: #3ecf8e;
--red: #f44;
--orange: #f5a623;
--radius: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 16px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 28px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
header h1 {
font-size: 24px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
header h1 span { color: var(--accent); }
.header-actions { display: flex; gap: 10px; }
.btn {
padding: 8px 18px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface2);
color: var(--text);
font-size: 15px;
cursor: pointer;
transition: all 0.15s;
}
.btn:hover { background: var(--border); }
.btn-accent {
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
}
.btn-accent:hover { background: var(--accent2); }
.btn-sm { padding: 5px 12px; font-size: 14px; }
.btn-danger { border-color: var(--red); color: var(--red); }
.btn-danger:hover { background: rgba(244,68,68,0.1); }
.btn-icon { padding: 6px 8px; font-size: 18px; line-height: 1; }
.btn-stop {
border-color: var(--red);
color: #fff;
background: var(--red);
}
.btn-stop:hover { background: #d83333; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
main { padding: 24px 28px; }
.pairs-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.pairs-header h2 { font-size: 18px; font-weight: 600; }
.pair-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
margin-bottom: 14px;
overflow: hidden;
transition: border-color 0.15s;
}
.pair-card:hover { border-color: #3a3f55; }
.pair-card.active { border-color: var(--accent); }
.pair-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
}
.pair-header:hover { background: #282c3a; }
.pair-title {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.pair-num {
background: var(--accent);
color: #fff;
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
}
.pair-status {
font-size: 13px;
padding: 3px 10px;
border-radius: 4px;
font-weight: 600;
}
.pair-status.idle { background: var(--surface); color: var(--text2); }
.pair-status.running { background: rgba(91,138,245,0.15); color: var(--accent); }
.pair-status.done { background: rgba(62,207,142,0.15); color: var(--green); }
.pair-status.error { background: rgba(244,68,68,0.15); color: var(--red); }
.pair-status.cancelled { background: rgba(245,166,35,0.15); color: var(--orange); }
.pair-body { padding: 18px; }
.pair-body.collapsed { display: none; }
.progress-wrap {
margin: 0 18px 14px 18px;
}
.progress-bar-track {
width: 100%;
height: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent2));
width: 0%;
transition: width 0.3s ease;
}
.progress-bar-fill.cancelled { background: var(--orange); }
.progress-bar-fill.error { background: var(--red); }
.progress-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text2);
margin-top: 4px;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.path-row {
display: flex;
gap: 14px;
margin-bottom: 18px;
}
.path-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 5px;
}
.path-group label {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text2);
}
.path-input {
display: flex;
gap: 0;
}
.path-input input {
flex: 1;
padding: 9px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius) 0 0 var(--radius);
color: var(--text);
font-size: 15px;
font-family: 'SF Mono', 'Fira Code', monospace;
outline: none;
}
.path-input input:focus { border-color: var(--accent); }
.path-input button {
padding: 9px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-left: none;
border-radius: 0 var(--radius) var(--radius) 0;
color: var(--text2);
cursor: pointer;
font-size: 16px;
}
.path-input button:hover { background: var(--border); color: var(--text); }
.arrow-center {
display: flex;
align-items: flex-end;
padding-bottom: 9px;
color: var(--text2);
font-size: 24px;
}
.mode-row {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.mode-group {
display: flex;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.mode-btn {
padding: 9px 24px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
border: none;
background: transparent;
color: var(--text2);
transition: all 0.15s;
}
.mode-btn.active {
background: var(--accent);
color: #fff;
}
.mode-btn:hover:not(.active) { background: var(--surface2); color: var(--text); }
.options-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 4px;
margin-bottom: 18px;
}
.option-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
}
.option-item:hover { background: var(--surface2); }
.option-item input[type="checkbox"] {
accent-color: var(--accent);
width: 17px;
height: 17px;
flex-shrink: 0;
}
.option-item label {
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.option-item .opt-name { font-weight: 600; font-family: 'SF Mono','Fira Code',monospace; color: var(--accent); min-width: 28px; }
.option-item .opt-desc { color: var(--text2); font-size: 13px; }
.option-item.danger-opt .opt-name { color: var(--red); }
.option-item.danger-opt { border: 1px solid transparent; }
.option-item.danger-opt:hover { border-color: rgba(255,68,68,0.3); background: rgba(255,68,68,0.05); }
.delete-warning {
display: none;
margin-bottom: 12px;
padding: 9px 14px;
background: rgba(244,68,68,0.1);
border: 1px solid rgba(244,68,68,0.4);
border-radius: var(--radius);
color: var(--red);
font-size: 14px;
font-weight: 600;
}
.delete-warning.show { display: block; }
/* 修正点: 再帰系オプション(-r/-a)が一つも有効でない時の常時警告バナー。
delete-warningと同じ見た目だが、橙色(--orange)で「危険」ではなく
「動作不正の可能性」を示す注意トーンにしている。 */
.recursive-warning {
display: none;
margin-bottom: 12px;
padding: 9px 14px;
background: rgba(245,166,35,0.12);
border: 1px solid rgba(245,166,35,0.45);
border-radius: var(--radius);
color: var(--orange);
font-size: 14px;
font-weight: 600;
}
.recursive-warning.show { display: block; }
.backup-dir-row {
display: none;
margin-bottom: 14px;
}
.backup-dir-row.show { display: flex; flex-direction: column; gap: 5px; }
.backup-dir-row label {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
color: var(--text2);
}
.backup-dir-row input {
padding: 8px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 15px;
font-family: 'SF Mono','Fira Code',monospace;
outline: none;
}
.backup-dir-row input:focus { border-color: var(--accent); }
.ssh-toggle {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.toggle {
position: relative;
width: 40px;
height: 22px;
background: var(--border);
border-radius: 11px;
cursor: pointer;
transition: background 0.2s;
}
.toggle.active { background: var(--accent); }
.toggle::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle.active::after { transform: translateX(18px); }
.ssh-toggle span { font-size: 14px; font-weight: 600; }
.ssh-config {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
margin-bottom: 18px;
padding: 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.ssh-config.hidden { display: none; }
.ssh-field { display: flex; flex-direction: column; gap: 5px; }
.ssh-field label {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
color: var(--text2);
}
.ssh-field input {
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 15px;
outline: none;
}
.ssh-field input:focus { border-color: var(--accent); }
.exclude-row {
margin-bottom: 18px;
}
.exclude-row label {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
color: var(--text2);
display: block;
margin-bottom: 5px;
}
.exclude-row textarea {
width: 100%;
height: 56px;
padding: 9px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 14px;
font-family: 'SF Mono', 'Fira Code', monospace;
resize: vertical;
outline: none;
}
.exclude-row textarea:focus { border-color: var(--accent); }
.pair-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.pair-actions-left { display: flex; gap: 10px; }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-overlay.hidden { display: none; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
width: 620px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 { font-size: 18px; font-weight: 600; }
.modal-close {
background: none;
border: none;
color: var(--text2);
font-size: 24px;
cursor: pointer;
}
.modal-close:hover { color: var(--text); }
.modal-body { flex: 1; overflow-y: auto; padding: 14px 22px; }
.modal-footer {
padding: 14px 22px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 4px;
padding: 9px 0;
font-size: 15px;
flex-wrap: wrap;
}
.breadcrumb-item {
color: var(--accent);
cursor: pointer;
padding: 3px 8px;
border-radius: 4px;
}
.breadcrumb-item:hover { background: rgba(91,138,245,0.1); }
.breadcrumb-sep { color: var(--text2); }
.folder-list { list-style: none; }
.folder-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 15px;
transition: background 0.1s;
}
.folder-item:hover { background: var(--surface2); }
.folder-item .icon { font-size: 18px; }
.folder-item .name { flex: 1; }
.folder-item .path { color: var(--text2); font-size: 13px; font-family: monospace; }
.folder-item.selected { background: rgba(91,138,245,0.15); border: 1px solid var(--accent); }
.log-panel {
margin-top: 28px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
}
.log-header h3 { font-size: 15px; font-weight: 600; }
.log-body {
padding: 14px 18px;
max-height: 320px;
overflow-y: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
color: var(--text2);
}
.log-body .stdout { color: var(--text); }
.log-body .stderr { color: var(--orange); }
.log-body .error { color: var(--red); }
.notification {
position: fixed;
top: 18px;
right: 18px;
padding: 14px 22px;
border-radius: var(--radius);
font-size: 15px;
font-weight: 600;
z-index: 2000;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.notification.success { background: rgba(62,207,142,0.15); color: var(--green); border: 1px solid var(--green); }
.notification.error { background: rgba(244,68,68,0.15); color: var(--red); border: 1px solid var(--red); }
.empty-state {
text-align: center;
padding: 44px;
color: var(--text2);
}
.empty-state .icon { font-size: 52px; margin-bottom: 14px; }
.empty-state p { font-size: 16px; }
.schedule-section {
margin-bottom: 18px;
padding: 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.schedule-section.hidden { display: none; }
.schedule-row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 8px;
}
.schedule-row:last-child { margin-bottom: 0; }
.schedule-row label {
font-size: 14px;
font-weight: 600;
min-width: 80px;
}
.schedule-row input[type="time"],
.schedule-row input[type="number"],
.schedule-row select {
padding: 6px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 14px;
outline: none;
}
.schedule-row input[type="time"]:focus,
.schedule-row input[type="number"]:focus,
.schedule-row select:focus { border-color: var(--accent); }
.schedule-row .days {
display: flex;
gap: 5px;
}
.schedule-row .day-btn {
width: 36px;
height: 30px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text2);
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.schedule-row .day-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.schedule-row .day-btn:hover { border-color: var(--accent); }
.schedule-desc {
font-size: 13px;
color: var(--text2);
margin-top: 10px;
padding: 8px 10px;
background: var(--surface);
border-radius: 6px;
}
.schedule-toggle {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.schedule-select {
padding: 6px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 14px;
outline: none;
}
.schedule-select:focus { border-color: var(--accent); }
.schedule-badge {
font-size: 13px;
padding: 3px 10px;
border-radius: 4px;
font-weight: 600;
background: rgba(62,207,142,0.15);
color: var(--green);
}
.schedule-badge.off {
background: var(--surface);
color: var(--text2);
}
.interval-group {
display: flex;
gap: 8px;
align-items: center;
}
.interval-group input[type="number"] {
width: 80px;
}
</style>
</head>
<body>
<header>
<h1><span>⚡</span> rsyncGUI</h1>
<div class="header-actions">
<button class="btn btn-sm" onclick="addPair()">+ ペア追加</button>
<button class="btn btn-sm" onclick="exportConfig()">📥 エクスポート</button>
<button class="btn btn-sm" onclick="document.getElementById('importFile').click()">📤 インポート</button>
<input type="file" id="importFile" accept=".json" style="display:none" onchange="importConfig(event)">
<button class="btn btn-sm" onclick="saveSchedule()" style="border-color:var(--green);color:var(--green)">⏰ スケジュール登録</button>
<button class="btn btn-sm" onclick="showScheduleCheck()" style="border-color:var(--orange);color:var(--orange)">📋 スケジュール確認</button>
<button class="btn btn-sm btn-accent" onclick="runAll()">▶ 全実行</button>
</div>
</header>
<main>
<div id="pairs"></div>
<div id="logPanel" class="log-panel" style="display:none">
<div class="log-header">
<h3>📋 実行ログ</h3>
<button class="btn btn-sm" onclick="clearLog()">クリア</button>
</div>
<div class="log-body" id="logBody"></div>
</div>
</main>
<div class="modal-overlay hidden" id="folderModal">
<div class="modal">
<div class="modal-header">
<h3>📁 フォルダを選択</h3>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body">
<div class="breadcrumb" id="breadcrumb"></div>
<ul class="folder-list" id="folderList"></ul>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal()">キャンセル</button>
<button class="btn btn-accent" id="selectBtn" onclick="selectFolder()">選択</button>
</div>
</div>
</div>
<div class="modal-overlay hidden" id="scheduleModal">
<div class="modal" style="width:700px">
<div class="modal-header">
<h3>📋 スケジュール確認</h3>
<button class="modal-close" onclick="closeScheduleModal()">×</button>
</div>
<div class="modal-body" id="scheduleModalBody">
<div style="text-align:center;color:var(--text2);padding:20px">読み込み中...</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeScheduleModal()">閉じる</button>
</div>
</div>
</div>
<script>
const RSYNC_OPTIONS = [
{ key: 'archive', short: 'a', desc: 'アーカイブモード(権限・タイムスタンプ保持)' },
{ key: 'verbose', short: 'v', desc: '詳細出力' },
{ key: 'compress', short: 'z', desc: '転送時圧縮' },
{ key: 'recursive', short: 'r', desc: 'サブディレクトリ再帰' },
{ key: 'times', short: 't', desc: 'タイムスタンプ維持' },
{ key: 'perms', short: 'p', desc: 'パーミッション維持' },
{ key: 'owner', short: 'o', desc: 'オーナー情報維持' },
{ key: 'group', short: 'g', desc: 'グループ情報維持' },
{ key: 'links', short: 'l', desc: 'シンボリックリンクを複製' },
{ key: 'hard-links', short: 'H', desc: 'ハードリンクを複製' },
{ key: 'partial', short: null, desc: '中断ファイルを保持(転送再開用)' },
{ key: 'progress', short: null, desc: '進捗表示' },
{ key: 'human-readable', short: 'h', desc: '人間が読めるサイズ表示(rsync 3.x以降)' },
{ key: 'checksum', short: 'c', desc: 'チェックサムで比較(タイムスタンプ無視)' },
{ key: 'itemize-changes', short: 'i', desc: '変更ファイルの詳細表示' },
{ key: 'stats', short: null, desc: '転送統計情報を表示' },
{ key: 'ignore-existing', short: null, desc: '転送先に既存ファイルがあればスキップ' },
{ key: 'update', short: 'u', desc: '転送元より新しいファイルのみ転送' },
{ key: 'delete', short: null, desc: 'ターゲット側の不要ファイルを削除', danger: true },
{ key: 'backup', short: 'b', desc: '上書き前にバックアップを作成' },
];
let pairs = [];
let nextId = 1;
let currentPairId = null;
let currentInputId = null;
let currentPath = '/';
let selectedFolder = null;
let pollingTimers = {};
const DAY_NAMES = ['日','月','火','水','木','金','土'];
// 修正点:
// - -r の自動付与をやめてチェックボックス完全依存にしたため、
// Copy選択時のデフォルトに recursive:true を含める(チェックが入った状態にする)。
// - Sync選択時のデフォルトから compress(-z) を外した。
// -z はSSHなど低速なネットワーク越しの転送でのみ有効に働き、
// ローカル/LAN間ではCPU負荷の方が大きくなりがちなため、
// 「とりあえずSync=圧縮あり」という決め打ちはしない方針にした。
// SSH転送を使う場合はオプション一覧からユーザーが個別にONにできる。
const SYNC_DEFAULTS = { archive: true, verbose: true, progress: true };
const COPY_DEFAULTS = { recursive: true, verbose: true, times: true, update: true, progress: true };
function createDefaultPair() {
return {
id: nextId++,
source: '',
target: '',
mode: 'copy',
sshEnabled: false,
ssh: { host: '', port: '22', user: '', password: '', key: '' },
options: { ...COPY_DEFAULTS },
'backup-dir': '',
exclude: '',
dryRun: false,
status: 'idle',
collapsed: false,
schedule: { enabled: false, mode: 'time', time: '02:00', days: [1,2,3,4,5,6,0], interval: 60, intervalUnit: 's' },
};
}
function ensurePair(p) {
if (!p.ssh) p.ssh = { host: '', port: '22', user: '', password: '', key: '' };
if (!p.options) p.options = { ...COPY_DEFAULTS };
if (!p.schedule) p.schedule = { enabled: false, mode: 'time', time: '02:00', days: [1,2,3,4,5,6,0], interval: 60, intervalUnit: 's' };
if (!p.schedule.mode) p.schedule.mode = 'time';
if (p.schedule.interval === undefined) p.schedule.interval = 60;
if (!p.schedule.intervalUnit) p.schedule.intervalUnit = 's';
if (p['backup-dir'] === undefined) p['backup-dir'] = '';
if (!p.progress) p.progress = { percent: 0, transferred: 0, rate: '', eta: '' };
if (p.jobId === undefined) p.jobId = null;
return p;
}
function addPair() {
pairs.push(createDefaultPair());
render();
}
function removePair(id) {
pairs = pairs.filter(p => p.id !== id);
render();
}
function toggleCollapse(id) {
const p = pairs.find(x => x.id === id);
if (p) p.collapsed = !p.collapsed;
render();
}
function setMode(id, mode) {
const p = pairs.find(x => x.id === id);
if (!p) return;
p.mode = mode;
// 修正点: -r はもうUI側で自動付与されないため、モード切替時に
// 明示的にチェックボックスへ反映する。Sync用デフォルト適用時は
// recursive を明示的にOFFへ(archiveの-rで代替されるのが通常想定のため)、
// Copy用デフォルト適用時は archive をOFFへ戻す。
if (mode === 'sync') {
Object.assign(p.options, SYNC_DEFAULTS);
p.options.recursive = false;
} else {
Object.assign(p.options, COPY_DEFAULTS);
p.options.archive = false;
}
render();
}
function toggleOption(id, key) {
const p = pairs.find(x => x.id === id);
if (!p) return;
p.options[key] = !p.options[key];
if (key === 'delete' || key === 'backup') render();
}
function toggleSSH(id) {
const p = pairs.find(x => x.id === id);
if (p) { p.sshEnabled = !p.sshEnabled; render(); }
}
function toggleSchedule(id) {
const p = pairs.find(x => x.id === id);
if (p) {
p.schedule.enabled = !p.schedule.enabled;
render();
}
}
function setScheduleMode(id, mode) {
const p = pairs.find(x => x.id === id);
if (p) { p.schedule.mode = mode; render(); }
}
function setScheduleTime(id, time) {
const p = pairs.find(x => x.id === id);
if (p) { p.schedule.time = time; render(); }
}
function setScheduleInterval(id, value) {
const p = pairs.find(x => x.id === id);
if (p) { p.schedule.interval = parseInt(value) || 1; render(); }
}
function setScheduleIntervalUnit(id, unit) {
const p = pairs.find(x => x.id === id);
if (p) { p.schedule.intervalUnit = unit; render(); }
}
function toggleScheduleDay(id, day) {
const p = pairs.find(x => x.id === id);
if (!p) return;
const s = p.schedule;
const idx = s.days.indexOf(day);
if (idx >= 0) s.days.splice(idx, 1);
else s.days.push(day);
s.days.sort();
render();
}
function formatInterval(interval, unit) {
const unitLabels = { 's': '秒', 'm': '分', 'h': '時間' };
return interval + unitLabels[unit] + 'ごと';
}
function getScheduleDesc(p) {
if (!p.schedule.enabled) return 'スケジュール: OFF';
const s = p.schedule;
if (s.mode === 'time') {
if (!s.days.length) return '未設定';
const dayStr = s.days.map(d => DAY_NAMES[d]).join('・');
return dayStr + ' ' + s.time;
} else {
return formatInterval(s.interval, s.intervalUnit);
}
}
function updateField(id, field, value) {
const p = pairs.find(x => x.id === id);
if (!p) return;
if (field.startsWith('ssh.')) {
p.ssh[field.slice(4)] = value;
} else {
p[field] = value;
}
}
function openFolderChooser(pairId, inputType) {
currentPairId = pairId;
currentInputId = inputType;
const p = pairs.find(x => x.id === pairId);
currentPath = (inputType === 'source' ? p.source : p.target) || '/';
if (!currentPath.endsWith('/')) currentPath += '/';
selectedFolder = null;
document.getElementById('folderModal').classList.remove('hidden');
browseTo(currentPath);
}
function closeModal() {
document.getElementById('folderModal').classList.add('hidden');
}
function selectFolder() {
if (!selectedFolder) return;
const p = pairs.find(x => x.id === currentPairId);
if (!p) return;
if (currentInputId === 'source') p.source = selectedFolder;
else p.target = selectedFolder;
closeModal();
render();
}
async function browseTo(dirPath) {
currentPath = dirPath;
const res = await fetch('/api/browse?path=' + encodeURIComponent(dirPath));
const data = await res.json();
if (data.error) return;
const bc = document.getElementById('breadcrumb');
const parts = data.current.split('/').filter(Boolean);
let html = '<span class="breadcrumb-item" onclick="browseTo(\'/\')">/</span>';
let accumulated = '';
for (const part of parts) {
accumulated += '/' + part;
const p = accumulated;
html += '<span class="breadcrumb-sep">/</span>';
html += '<span class="breadcrumb-item" onclick="browseTo(\'' + p.replace(/'/g, "\\'") + '\')">' + part + '</span>';
}
bc.innerHTML = html;
const list = document.getElementById('folderList');
let items = '';
if (data.parent && data.parent !== data.current) {
items += '<li class="folder-item" onclick="browseTo(\'' + data.parent.replace(/'/g, "\\'") + '\')">';
items += '<span class="icon">⬆</span><span class="name">..</span></li>';
}
for (const item of data.items) {
const sel = selectedFolder === item.path ? ' selected' : '';
items += '<li class="folder-item' + sel + '" onclick="selectItem(this, \'' + item.path.replace(/'/g, "\\'") + '\')">';
items += '<span class="icon">📁</span><span class="name">' + item.name + '</span>';
items += '<span class="path">' + item.path + '</span></li>';
}
if (!data.items.length && !data.parent) {
items = '<li class="folder-item"><span class="name">(フォルダなし)</span></li>';
}
list.innerHTML = items;
}
function selectItem(el, path) {
selectedFolder = path;
document.querySelectorAll('.folder-item').forEach(x => x.classList.remove('selected'));
el.classList.add('selected');
}
function formatBytes(n) {
if (!n || n <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + ' ' + units[i];
}
async function runPair(id) {
const p = pairs.find(x => x.id === id);
if (!p || !p.source || !p.target) {
notify('ソースとターゲットを指定してください', 'error');
return;
}
p.status = 'running';
p.progress = { percent: 0, transferred: 0, rate: '', eta: '' };
render();
try {
const opts = { ...p.options };
const body = {
source: p.source,
target: p.target,
mode: p.mode,
options: opts,
'backup-dir': p['backup-dir'] || '',
ssh: p.sshEnabled ? p.ssh : null,
dryRun: p.dryRun,
exclude: p.exclude,
};
const res = await fetch('/api/rsync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
p.jobId = data.id;
render();
showLog('▶ ' + data.command);
pollStatus(id, data.id);
} catch (e) {
p.status = 'error';
render();
notify('エラー: ' + e.message, 'error');
}
}
async function cancelPair(id) {
const p = pairs.find(x => x.id === id);
if (!p || !p.jobId) return;
if (!confirm('実行中の処理を中断しますか?')) return;
try {
await fetch('/api/cancel/' + p.jobId, { method: 'POST' });
showLog('■ 中断リクエストを送信しました');
} catch (e) {
notify('中断リクエストに失敗しました: ' + e.message, 'error');
}
}
function pollStatus(pairId, jobId) {
const timer = setInterval(async () => {
try {
const res = await fetch('/api/status/' + jobId);
const data = await res.json();
for (const line of data.log) {
showLog(line.text, line.type);
}
data.log.length = 0;
const p = pairs.find(x => x.id === pairId);
if (p && data.progress) {
p.progress = data.progress;
updateProgressUI(pairId, p.progress, p.status);
}
if (data.done) {
clearInterval(timer);
delete pollingTimers[pairId];
if (p) {
p.jobId = null;
if (data.cancelled) {
p.status = 'cancelled';
} else {
p.status = data.exitCode === 0 ? 'done' : 'error';
}
if (p.status === 'done') {
p.progress = { percent: 100, transferred: p.progress.transferred, rate: '', eta: '' };
}
}
render();
if (data.cancelled) {
notify('中断しました', 'error');
} else {
notify(data.exitCode === 0 ? '完了' : 'エラー終了', data.exitCode === 0 ? 'success' : 'error');
}
}
} catch (e) {
clearInterval(timer);
delete pollingTimers[pairId];
}
}, 500);
pollingTimers[pairId] = timer;
}
// 修正点: DOM全体を再描画する render() を進捗更新のたびに呼ぶと
// 入力中のテキストフィールド等のフォーカスが失われるため、
// 進捗バーだけを直接更新する軽量パスを用意。
function updateProgressUI(pairId, progress, status) {
const fill = document.getElementById('progress-fill-' + pairId);
const pct = document.getElementById('progress-pct-' + pairId);
const meta = document.getElementById('progress-meta-' + pairId);
if (!fill) return;
const percent = Math.max(0, Math.min(100, progress.percent || 0));
fill.style.width = percent + '%';
fill.classList.remove('cancelled', 'error');
if (status === 'cancelled') fill.classList.add('cancelled');
if (status === 'error') fill.classList.add('error');
if (pct) pct.textContent = percent + '%';
if (meta) {
const parts = [];
if (progress.transferred) parts.push(formatBytes(progress.transferred) + ' 転送済み');
if (progress.rate) parts.push(progress.rate);
if (progress.eta) parts.push('ETA ' + progress.eta);
meta.textContent = parts.join(' / ');
}
}
function runAll() {
for (const p of pairs) {
if (p.source && p.target) runPair(p.id);
}
}
function showLog(text, type) {
const panel = document.getElementById('logPanel');
const body = document.getElementById('logBody');
panel.style.display = 'block';
const span = document.createElement('span');
span.className = type || '';
span.textContent = text;
body.appendChild(span);
body.appendChild(document.createElement('br'));
body.scrollTop = body.scrollHeight;
}
function clearLog() {
document.getElementById('logBody').innerHTML = '';
document.getElementById('logPanel').style.display = 'none';
}
function notify(msg, type) {
const el = document.createElement('div');
el.className = 'notification ' + type;
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
function render() {
const container = document.getElementById('pairs');
if (!pairs.length) {
container.innerHTML = '<div class="empty-state"><div class="icon">📂</div><p>ペアを追加してrsyncを開始しましょう</p></div>';
return;
}
let html = '';
for (const p of pairs) {
html += renderPair(p);
}
container.innerHTML = html;
}
function renderPair(p) {
p = ensurePair(p);
const statusLabel = { idle: '待機中', running: '実行中', done: '完了', error: 'エラー', cancelled: '中断' }[p.status] || p.status;
const modeBtn = (m, label) => `<button class="mode-btn${p.mode === m ? ' active' : ''}" onclick="setMode(${p.id},'${m}')">${label}</button>`;
const optItem = (o) => {
const flagLabel = o.short ? `-${o.short} / --${o.key}` : `--${o.key}`;
const dangerClass = o.danger ? ' danger-opt' : '';
return `<label class="option-item${dangerClass}">` +
`<input type="checkbox" ${p.options[o.key] ? 'checked' : ''} onchange="toggleOption(${p.id},'${o.key}')">` +
`<label><span class="opt-name">${flagLabel}</span> <span class="opt-desc">${o.desc}</span></label>` +
`</label>`;
};
const showDeleteWarning = p.options['delete'] ? 'show' : '';
const showBackupDir = p.options['backup'] ? 'show' : '';
// 修正点: 再帰系オプション(-r/--recursive または -a/--archive)が
// どちらも有効でない場合、サブディレクトリの中身が転送されない
// (rsyncはデフォルトでは再帰しない)ため、常時警告を表示する。
const hasRecursion = !!(p.options['recursive'] || p.options['archive']);
const showRecursiveWarning = hasRecursion ? '' : 'show';
const scheduleTimeInput = p.schedule.mode === 'time'
? `<input type="time" value="${esc(p.schedule.time)}" onchange="setScheduleTime(${p.id},this.value)">`
: `<div class="interval-group">
<input type="number" min="1" value="${p.schedule.interval}" onchange="setScheduleInterval(${p.id},this.value)">
<select onchange="setScheduleIntervalUnit(${p.id},this.value)">
<option value="s"${p.schedule.intervalUnit === 's' ? ' selected' : ''}>秒</option>
<option value="m"${p.schedule.intervalUnit === 'm' ? ' selected' : ''}>分</option>
<option value="h"${p.schedule.intervalUnit === 'h' ? ' selected' : ''}>時間</option>
</select>
</div>`;
const scheduleTimeLabel = p.schedule.mode === 'time' ? '時刻' : '間隔';
const daysHtml = p.schedule.mode === 'time'
? `<div class="schedule-row">
<label>曜日</label>
<div class="days">
${DAY_NAMES.map((d,i) => '<div class="day-btn' + (p.schedule.days.includes(i) ? ' active' : '') + '" onclick="toggleScheduleDay(' + p.id + ',' + i + ')">' + d + '</div>').join('')}
</div>
</div>`
: '';
// 修正点: 実行中・完了・エラー・中断のいずれかで進捗バーを表示する。
// idle のときは非表示(display:none)にしておき、実行開始時に見えるようにする。
const showProgress = p.status !== 'idle';
const progressPercent = Math.max(0, Math.min(100, p.progress ? p.progress.percent : 0));
const progressFillClass = p.status === 'cancelled' ? ' cancelled' : (p.status === 'error' ? ' error' : '');
const progressMetaParts = [];
if (p.progress && p.progress.transferred) progressMetaParts.push(formatBytes(p.progress.transferred) + ' 転送済み');
if (p.progress && p.progress.rate) progressMetaParts.push(p.progress.rate);
if (p.progress && p.progress.eta) progressMetaParts.push('ETA ' + p.progress.eta);
const runStopButtons = p.status === 'running'
? `<button class="btn btn-stop" onclick="cancelPair(${p.id})">■ 中断</button>`
: `<button class="btn btn-accent" onclick="runPairForce(${p.id})">▶ 実行</button>
<button class="btn" onclick="runPairDry(${p.id})">🔍 ドライラン</button>`;
return `
<div class="pair-card ${p.status === 'running' ? 'active' : ''}">
<div class="pair-header" onclick="toggleCollapse(${p.id})">
<div class="pair-title">
<span class="pair-num">${p.id}</span>
${p.source ? '<span style="color:var(--text2);font-size:14px">' + shorten(p.source) + '</span>' : '<span style="color:var(--text2);font-size:14px">未設定</span>'}
<span style="color:var(--text2)">→</span>
${p.target ? '<span style="color:var(--text2);font-size:14px">' + shorten(p.target) + '</span>' : '<span style="color:var(--text2);font-size:14px">未設定</span>'}
</div>
<div style="display:flex;align-items:center;gap:10px">
${p.schedule.enabled ? '<span class="schedule-badge">' + getScheduleDesc(p) + '</span>' : ''}
<span class="pair-status ${p.status}">${statusLabel}</span>
</div>
</div>
<div class="progress-wrap" style="${showProgress ? '' : 'display:none'}">
<div class="progress-bar-track">
<div class="progress-bar-fill${progressFillClass}" id="progress-fill-${p.id}" style="width:${progressPercent}%"></div>
</div>
<div class="progress-meta">
<span id="progress-pct-${p.id}">${progressPercent}%</span>
<span id="progress-meta-${p.id}">${progressMetaParts.join(' / ')}</span>
</div>
</div>
<div class="pair-body${p.collapsed ? ' collapsed' : ''}">
<div class="path-row">
<div class="path-group">
<label>ソースフォルダ</label>
<div class="path-input">
<input type="text" value="${esc(p.source)}" placeholder="/path/to/source" onchange="updateField(${p.id},'source',this.value)">
<button onclick="openFolderChooser(${p.id},'source')">📁</button>
</div>
</div>
<div class="arrow-center">→</div>
<div class="path-group">
<label>ターゲットフォルダ</label>
<div class="path-input">
<input type="text" value="${esc(p.target)}" placeholder="/path/to/target" onchange="updateField(${p.id},'target',this.value)">
<button onclick="openFolderChooser(${p.id},'target')">📁</button>
</div>
</div>
</div>
<div class="mode-row">
<div class="mode-group">
${modeBtn('copy', '📋 Copy')}
${modeBtn('sync', '🔄 Sync')}
</div>
<div class="ssh-toggle">
<div class="toggle${p.sshEnabled ? ' active' : ''}" onclick="toggleSSH(${p.id})"></div>
<span>SSH リモート接続</span>
</div>
</div>
<div class="ssh-config${p.sshEnabled ? '' : ' hidden'}">
<div class="ssh-field">
<label>ホスト名 / IP</label>
<input type="text" value="${esc(p.ssh.host)}" placeholder="192.168.1.100" onchange="updateField(${p.id},'ssh.host',this.value)">
</div>
<div class="ssh-field">
<label>ポート</label>
<input type="text" value="${esc(p.ssh.port)}" placeholder="22" onchange="updateField(${p.id},'ssh.port',this.value)">
</div>
<div class="ssh-field">
<label>ユーザー名</label>
<input type="text" value="${esc(p.ssh.user)}" placeholder="root" onchange="updateField(${p.id},'ssh.user',this.value)">
</div>
<div class="ssh-field">
<label>パスワード</label>
<input type="password" value="${esc(p.ssh.password)}" placeholder="(パスワード認証の場合)" onchange="updateField(${p.id},'ssh.password',this.value)">
</div>
<div class="ssh-field">
<label>SSH秘密鍵パス</label>
<input type="text" value="${esc(p.ssh.key)}" placeholder="~/.ssh/id_rsa" onchange="updateField(${p.id},'ssh.key',this.value)">
</div>
</div>
<div class="options-grid">
${RSYNC_OPTIONS.map(o => optItem(o)).join('')}
</div>
<div class="recursive-warning ${showRecursiveWarning}">
⚠️ 再帰オプション(-r または -a)が有効になっていません。このままではサブディレクトリの中身が転送されません。
</div>
<div class="delete-warning ${showDeleteWarning}">
⚠️ --delete が有効です。ターゲット側にあってソース側にないファイルが削除されます。
</div>
<div class="backup-dir-row ${showBackupDir}">
<label>バックアップ先ディレクトリ(--backup-dir)</label>
<input type="text" value="${esc(p['backup-dir'])}" placeholder="/path/to/backup (空白時はターゲットと同じ場所に ~suffix)"
onchange="updateField(${p.id},'backup-dir',this.value)">
</div>
<div class="exclude-row">
<label>除外パターン(1行1パターン)</label>
<textarea placeholder="*.log .git/ node_modules/" onchange="updateField(${p.id},'exclude',this.value)">${esc(p.exclude)}</textarea>
</div>
<div class="schedule-section">
<div class="schedule-toggle">
<div class="toggle${p.schedule.enabled ? ' active' : ''}" onclick="toggleSchedule(${p.id})"></div>
<span style="font-size:14px;font-weight:600">スケジュール実行</span>
${p.schedule.enabled ? '<span class="schedule-badge">' + getScheduleDesc(p) + '</span>' : '<span class="schedule-badge off">OFF</span>'}
</div>
<div class="${p.schedule.enabled ? '' : 'hidden'}" style="margin-top:14px">
<div class="schedule-row">
<label>モード</label>
<select class="schedule-select" onchange="setScheduleMode(${p.id},this.value)">
<option value="time"${p.schedule.mode === 'time' ? ' selected' : ''}>指定時刻</option>
<option value="interval"${p.schedule.mode === 'interval' ? ' selected' : ''}>指定間隔</option>
</select>
</div>
<div class="schedule-row">
<label>${scheduleTimeLabel}</label>
${scheduleTimeInput}
</div>
${daysHtml}
<div class="schedule-desc">${getScheduleDesc(p)}</div>
</div>
</div>
<div class="pair-actions">
<div class="pair-actions-left">
${runStopButtons}
</div>
<button class="btn btn-danger btn-sm" onclick="removePair(${p.id})" ${p.status === 'running' ? 'disabled' : ''}>🗑 削除</button>
</div>
</div>
</div>`;
}
function runPairDry(id) {
const p = pairs.find(x => x.id === id);
if (p) {
p.dryRun = true;
runPair(id).then(() => {
p.dryRun = false;
});
}
}
function runPairForce(id) {
const p = pairs.find(x => x.id === id);
if (p) { p.dryRun = false; runPair(id); }
}
function shorten(s) {
if (!s) return '';
const parts = s.split('/').filter(Boolean);
if (parts.length <= 2) return s;
return '…/' + parts.slice(-2).join('/');
}
function esc(s) {
return (s || '').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
}
function exportConfig() {
const data = {
pairs: pairs.map(p => {
const clean = { ...p };
delete clean.status;
delete clean.collapsed;
delete clean.progress;
delete clean.jobId;
return clean;
}),
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'rsyncgui-config-' + new Date().toISOString().slice(0,10) + '.json';
a.click();
URL.revokeObjectURL(url);
notify('エクスポート完了', 'success');
}
function importConfig(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const data = JSON.parse(e.target.result);
if (data.pairs && Array.isArray(data.pairs)) {
pairs = data.pairs.map(ensurePair);
nextId = Math.max(...pairs.map(p => p.id), 0) + 1;
render();
notify('インポート完了: ' + pairs.length + 'ペア読み込み', 'success');
} else {
notify('無効な設定ファイルです', 'error');
}
} catch (err) {
notify('パースエラー: ' + err.message, 'error');
}
};
reader.readAsText(file);
event.target.value = '';
}
async function saveSchedule() {
try {
const res = await fetch('/api/schedule/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pairs: pairs }),
});
const data = await res.json();
if (data.ok) {
notify('スケジュールを登録しました', 'success');
} else {
notify('登録エラー: ' + (data.error || '不明'), 'error');
}
} catch (e) {
notify('通信エラー: ' + e.message, 'error');
}
}
function showScheduleCheck() {
document.getElementById('scheduleModal').classList.remove('hidden');
loadScheduleCheck();
}
function closeScheduleModal() {
document.getElementById('scheduleModal').classList.add('hidden');
}
async function loadScheduleCheck() {
const body = document.getElementById('scheduleModalBody');
body.innerHTML = '<div style="text-align:center;color:var(--text2);padding:20px">読み込み中...</div>';
try {
const res = await fetch('/api/cron');
const data = await res.json();
const entries = data.entries || [];
const intervals = data.intervals || [];
const rsyncEntries = entries.filter(e => e.includes('rsync') || e.includes('rsyncgui'));
const otherEntries = entries.filter(e => !e.includes('rsync') && !e.includes('rsyncgui'));
let html = '';
html += '<div style="margin-bottom:20px">';
html += '<h4 style="font-size:15px;font-weight:600;color:var(--green);margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">🔄 rsync関連スケジュール (' + (rsyncEntries.length + intervals.length) + '件)</h4>';
if (rsyncEntries.length === 0 && intervals.length === 0) {
html += '<div style="color:var(--text2);font-size:14px;padding:10px">登録なし</div>';
} else {
html += '<div style="font-family:monospace;font-size:13px;line-height:1.8">';
for (const entry of rsyncEntries) {
const parts = entry.split(/\s+/);
const cronTime = parts.slice(0, 5).join(' ');
const comment = entry.match(/# rsyncgui-managed id=(\d+)/);
const pairId = comment ? comment[1] : '?';
const pair = pairs.find(p => p.id === parseInt(pairId));
const label = pair ? (shorten(pair.source) + ' → ' + shorten(pair.target)) : '(ペア#' + pairId + ')';
html += '<div style="padding:6px 10px;border-radius:4px;margin-bottom:4px;background:var(--surface)">';
html += '<span style="color:var(--accent)">' + cronTime + '</span> ';
html += '<span style="color:var(--text)">' + label + '</span>';
html += '</div>';
}
for (const iv of intervals) {
const pair = pairs.find(p => p.id === iv.id);
const label = pair ? (shorten(pair.source) + ' → ' + shorten(pair.target)) : '(ペア#' + iv.id + ')';
html += '<div style="padding:6px 10px;border-radius:4px;margin-bottom:4px;background:var(--surface)">';
html += '<span style="color:var(--orange)">' + iv.intervalLabel + '</span> ';
html += '<span style="color:var(--text)">' + label + '</span>';
html += '</div>';
}
html += '</div>';
}
html += '</div>';
html += '<div>';
html += '<h4 style="font-size:15px;font-weight:600;color:var(--text2);margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">⚙️ その他のスケジュール (' + otherEntries.length + '件)</h4>';
if (otherEntries.length === 0) {
html += '<div style="color:var(--text2);font-size:14px;padding:10px">登録なし</div>';
} else {
html += '<div style="font-family:monospace;font-size:13px;line-height:1.8">';
for (const entry of otherEntries) {
html += '<div style="padding:6px 10px;border-radius:4px;margin-bottom:4px;background:var(--surface);word-break:break-all">';
html += '<span style="color:var(--text)">' + escHtml(entry) + '</span>';
html += '</div>';
}
html += '</div>';
}
html += '</div>';
body.innerHTML = html;
} catch (e) {
body.innerHTML = '<div style="color:var(--red);padding:20px">読み込みエラー: ' + e.message + '</div>';
}
}
function escHtml(s) {
return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
async function loadConfig() {
try {
const res = await fetch('/api/config');
const cfg = await res.json();
if (cfg.pairs && cfg.pairs.length) {
pairs = cfg.pairs.map(ensurePair);
nextId = Math.max(...pairs.map(p => p.id), 0) + 1;
} else {
addPair();
}
} catch (e) {
addPair();
}
render();
}
loadConfig();
</script>
</body>
</html>
HTMLEOF
echo "[5/6] Creating systemd service..."
cat > /etc/systemd/system/${SERVICE_NAME}.service << SVCEOF
[Unit]
Description=rsyncGUI - Web-based rsync interface
After=network.target
[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/python3 ${INSTALL_DIR}/server.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SVCEOF
echo "[6/6] Starting service..."
systemctl daemon-reload
systemctl enable ${SERVICE_NAME}
systemctl restart ${SERVICE_NAME}
echo ""
echo "=== Done! ==="
echo "rsyncGUI is running at http://localhost:${PORT}"
echo ""
echo "Commands:"
echo " systemctl status ${SERVICE_NAME}"
echo " systemctl restart ${SERVICE_NAME}"
echo " journalctl -u ${SERVICE_NAME} -f"
アンインストール
# サービス停止・無効化
systemctl stop rsyncgui
systemctl disable rsyncgui
# サービスファイル削除
rm /etc/systemd/system/rsyncgui.service
systemctl daemon-reload
# インストールディレクトリ削除(設定・ログも含む)
rm -rf /opt/rsyncgui
# crontab に登録したスケジュールを削除
crontab -l | grep -v '# rsyncgui-managed' | crontab -


