rsyncの設定をWebで行える「rsyncGUI」

rsyncの設定をGUIで行えるツールを作ってみました。複数のペアを登録し、スケジュール実行出来るので、日々のバックアップなどに活用出来ると思います。

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] Installing dependencies..."
case $PKG in
    apt)
        apt-get update -qq
        apt-get install -y -qq python3 rsync sshpass openssh-client
        ;;
    yum)
        yum install -y -q python3 rsync sshpass openssh-clients
        ;;
    dnf)
        dnf install -y -q python3 rsync sshpass openssh-clients
        ;;
    apk)
        apk add --no-cache python3 rsync sshpass openssh-client
        ;;
esac

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 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"

MIME = {
    ".html": "text/html",
    ".css": "text/css",
    ".js": "application/javascript",
    ".json": "application/json",
    ".svg": "image/svg+xml",
    ".png": "image/png",
}

processes = {}


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):
    args = []
    mode = job.get("mode", "sync")
    if mode == "copy":
        args.append("-r")
    elif mode == "sync":
        args.append("-avz")

    options = job.get("options", {})
    for key, val in options.items():
        if 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")

    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
        pattern_idx = sched.get("pattern", 0)
        patterns = [
            {"time": "02:00", "days": [1,2,3,4,5,6,0]},
            {"time": "12:00", "days": [1,3,5]},
            {"time": "23:00", "days": [6,0]},
        ]
        if pattern_idx >= len(patterns):
            continue
        pat = patterns[pattern_idx]
        cron_time = schedule_to_cron_time(pat)
        if not cron_time:
            continue
        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)


class Handler(http.server.BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        pass

    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.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)
        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 _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)
            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]
        self._json_response({"entries": lines})

    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

                    proc = subprocess.Popen(
                        cmd_list,
                        stdout=subprocess.PIPE,
                        stderr=subprocess.PIPE,
                        text=True,
                    )
                    processes[job_id] = {"proc": proc, "log": [], "done": False, "exitCode": None}
                    for line in proc.stdout:
                        processes[job_id]["log"].append({"type": "stdout", "text": line})
                    for line in proc.stderr:
                        processes[job_id]["log"].append({"type": "stderr", "text": line})
                    proc.wait()
                    processes[job_id]["done"] = True
                    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 _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,
        })


def main():
    server = http.server.HTTPServer(("0.0.0.0", PORT), Handler)
    print(f"rsyncGUI running at http://localhost:{PORT}")

    def shutdown(sig, frame):
        print("\nShutting down...")
        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>
<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;
}
header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 24px;
  border-bottom: 1px solid var(--border);
  background: var(--surface);
}
header h1 {
  font-size: 18px;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
}
header h1 span { color: var(--accent); }
.header-actions { display: flex; gap: 8px; }

.btn {
  padding: 7px 16px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface2);
  color: var(--text);
  font-size: 13px;
  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: 4px 10px; font-size: 12px; }
.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: 16px; line-height: 1; }

main { padding: 20px 24px; }

.pairs-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 16px;
}
.pairs-header h2 { font-size: 15px; font-weight: 600; }

.pair-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  margin-bottom: 12px;
  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: 10px 16px;
  background: var(--surface2);
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  user-select: none;
}
.pair-header:hover { background: #282c3a; }
.pair-title {
  font-size: 13px;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
}
.pair-num {
  background: var(--accent);
  color: #fff;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 700;
}
.pair-status {
  font-size: 11px;
  padding: 2px 8px;
  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-body { padding: 16px; }
.pair-body.collapsed { display: none; }

.path-row {
  display: flex;
  gap: 12px;
  margin-bottom: 16px;
}
.path-group {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.path-group label {
  font-size: 11px;
  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: 8px 12px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius) 0 0 var(--radius);
  color: var(--text);
  font-size: 13px;
  font-family: 'SF Mono', 'Fira Code', monospace;
  outline: none;
}
.path-input input:focus { border-color: var(--accent); }
.path-input button {
  padding: 8px 10px;
  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: 14px;
}
.path-input button:hover { background: var(--border); color: var(--text); }

.arrow-center {
  display: flex;
  align-items: flex-end;
  padding-bottom: 8px;
  color: var(--text2);
  font-size: 20px;
}

.mode-row {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-bottom: 16px;
  flex-wrap: wrap;
}
.mode-group {
  display: flex;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}
.mode-btn {
  padding: 8px 20px;
  font-size: 13px;
  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(220px, 1fr));
  gap: 4px;
  margin-bottom: 16px;
}
.option-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 8px;
  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: 15px;
  height: 15px;
}
.option-item label {
  font-size: 12px;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 6px;
}
.option-item .opt-name { font-weight: 600; }
.option-item .opt-desc { color: var(--text2); font-size: 11px; }

.ssh-toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 12px;
}
.toggle {
  position: relative;
  width: 36px;
  height: 20px;
  background: var(--border);
  border-radius: 10px;
  cursor: pointer;
  transition: background 0.2s;
}
.toggle.active { background: var(--accent); }
.toggle::after {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: 16px;
  height: 16px;
  background: #fff;
  border-radius: 50%;
  transition: transform 0.2s;
}
.toggle.active::after { transform: translateX(16px); }
.ssh-toggle span { font-size: 12px; font-weight: 600; }

.ssh-config {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 8px;
  margin-bottom: 16px;
  padding: 12px;
  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: 4px; }
.ssh-field label {
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  color: var(--text2);
}
.ssh-field input {
  padding: 7px 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 13px;
  outline: none;
}
.ssh-field input:focus { border-color: var(--accent); }

.exclude-row {
  margin-bottom: 16px;
}
.exclude-row label {
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  color: var(--text2);
  display: block;
  margin-bottom: 4px;
}
.exclude-row textarea {
  width: 100%;
  height: 50px;
  padding: 8px 12px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  font-size: 12px;
  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: 12px;
  border-top: 1px solid var(--border);
}
.pair-actions-left { display: flex; gap: 8px; }

.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: 600px;
  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: 16px 20px;
  border-bottom: 1px solid var(--border);
}
.modal-header h3 { font-size: 15px; font-weight: 600; }
.modal-close {
  background: none;
  border: none;
  color: var(--text2);
  font-size: 20px;
  cursor: pointer;
}
.modal-close:hover { color: var(--text); }
.modal-body { flex: 1; overflow-y: auto; padding: 12px 20px; }
.modal-footer {
  padding: 12px 20px;
  border-top: 1px solid var(--border);
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

.breadcrumb {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 8px 0;
  font-size: 13px;
  flex-wrap: wrap;
}
.breadcrumb-item {
  color: var(--accent);
  cursor: pointer;
  padding: 2px 6px;
  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: 8px 12px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 13px;
  transition: background 0.1s;
}
.folder-item:hover { background: var(--surface2); }
.folder-item .icon { font-size: 16px; }
.folder-item .name { flex: 1; }
.folder-item .path { color: var(--text2); font-size: 11px; font-family: monospace; }
.folder-item.selected { background: rgba(91,138,245,0.15); border: 1px solid var(--accent); }

.log-panel {
  margin-top: 24px;
  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: 10px 16px;
  background: var(--surface2);
  border-bottom: 1px solid var(--border);
}
.log-header h3 { font-size: 13px; font-weight: 600; }
.log-body {
  padding: 12px 16px;
  max-height: 300px;
  overflow-y: auto;
  font-family: 'SF Mono', 'Fira Code', monospace;
  font-size: 12px;
  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: 16px;
  right: 16px;
  padding: 12px 20px;
  border-radius: var(--radius);
  font-size: 13px;
  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: 40px;
  color: var(--text2);
}
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
.empty-state p { font-size: 14px; }

.schedule-section {
  margin-bottom: 16px;
  padding: 12px;
  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: 12px;
  margin-bottom: 6px;
}
.schedule-row:last-child { margin-bottom: 0; }
.schedule-row label {
  font-size: 12px;
  font-weight: 600;
  min-width: 80px;
}
.schedule-row input[type="time"],
.schedule-row select {
  padding: 5px 8px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 12px;
  outline: none;
}
.schedule-row input[type="time"]:focus,
.schedule-row select:focus { border-color: var(--accent); }
.schedule-row .days {
  display: flex;
  gap: 4px;
}
.schedule-row .day-btn {
  width: 28px;
  height: 24px;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--surface);
  color: var(--text2);
  font-size: 10px;
  font-weight: 600;
  cursor: pointer;
}
.schedule-row .day-btn.active {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}
.schedule-desc {
  font-size: 11px;
  color: var(--text2);
  margin-top: 8px;
  padding: 6px 8px;
  background: var(--surface);
  border-radius: 4px;
}
.schedule-toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 8px;
}
.schedule-select {
  padding: 5px 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 12px;
  outline: none;
}
.schedule-select:focus { border-color: var(--accent); }
.schedule-badge {
  font-size: 11px;
  padding: 2px 8px;
  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);
}
</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 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>

<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: 'delete', short: null, desc: 'ターゲット側の不要ファイル削除' },
  { key: 'partial', short: 'P', desc: '中断ファイルを保持' },
  { key: 'progress', short: null, desc: '進捗表示' },
  { key: 'human-readable', short: 'h', desc: '人間が読めるサイズ表示' },
  { 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: 'backup', short: 'b', desc: '上書き前にバックアップ' },
];

let pairs = [];
let nextId = 1;
let currentPairId = null;
let currentInputId = null;
let currentPath = '/';
let selectedFolder = null;
let pollingTimers = {};

const SCHEDULES = [
  { name: '設定1', time: '02:00', days: [1,2,3,4,5,6,0], desc: '毎日 02:00' },
  { name: '設定2', time: '12:00', days: [1,3,5], desc: '月・水・金 12:00' },
  { name: '設定3', time: '23:00', days: [6,0], desc: '土・日 23:00' },
];
const DAY_NAMES = ['日','月','火','水','木','金','土'];

function createDefaultPair() {
  return {
    id: nextId++,
    source: '',
    target: '',
    mode: 'sync',
    sshEnabled: false,
    ssh: { host: '', port: '22', user: '', password: '', key: '' },
    options: { archive: true, verbose: true, compress: true, progress: true },
    exclude: '',
    dryRun: false,
    status: 'idle',
    collapsed: false,
    schedule: { enabled: false, pattern: 0 },
  };
}

function ensurePair(p) {
  if (!p.ssh) p.ssh = { host: '', port: '22', user: '', password: '', key: '' };
  if (!p.options) p.options = { archive: true, verbose: true, compress: true, progress: true };
  if (!p.schedule) p.schedule = { enabled: false, pattern: 0 };
  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) { p.mode = mode; render(); }
}

function toggleOption(id, key) {
  const p = pairs.find(x => x.id === id);
  if (p) p.options[key] = !p.options[key];
}

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 setSchedulePattern(id, idx) {
  const p = pairs.find(x => x.id === id);
  if (p) { p.schedule.pattern = parseInt(idx); render(); }
}

function setScheduleTime(id, time) {
  const p = pairs.find(x => x.id === id);
  if (p) SCHEDULES[p.schedule.pattern].time = time;
}

function toggleScheduleDay(id, day) {
  const p = pairs.find(x => x.id === id);
  if (!p) return;
  const s = SCHEDULES[p.schedule.pattern];
  const idx = s.days.indexOf(day);
  if (idx >= 0) s.days.splice(idx, 1);
  else s.days.push(day);
  s.days.sort();
  render();
}

function getScheduleDesc(p) {
  if (!p.schedule.enabled) return 'スケジュール: OFF';
  const s = SCHEDULES[p.schedule.pattern];
  if (!s.days.length) return s.name + ': 未設定';
  const dayStr = s.days.map(d => DAY_NAMES[d]).join('・');
  return s.name + ': ' + dayStr + ' ' + s.time;
}

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');
}

async function runPair(id) {
  const p = pairs.find(x => x.id === id);
  if (!p || !p.source || !p.target) {
    notify('ソースとターゲットを指定してください', 'error');
    return;
  }
  p.status = 'running';
  render();
  try {
    const body = {
      source: p.source,
      target: p.target,
      mode: p.mode,
      options: p.options,
      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();
    showLog('▶ ' + data.command);
    pollStatus(id, data.id);
  } catch (e) {
    p.status = 'error';
    render();
    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;
      if (data.done) {
        clearInterval(timer);
        const p = pairs.find(x => x.id === pairId);
        if (p) p.status = data.exitCode === 0 ? 'done' : 'error';
        render();
        notify(data.exitCode === 0 ? '完了' : 'エラー終了', data.exitCode === 0 ? 'success' : 'error');
      }
    } catch (e) {
      clearInterval(timer);
    }
  }, 500);
  pollingTimers[pairId] = timer;
}

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: 'エラー' }[p.status];
  const modeBtn = (m, label) => `<button class="mode-btn${p.mode === m ? ' active' : ''}" onclick="setMode(${p.id},'${m}')">${label}</button>`;
  const optItem = (o) => `<label class="option-item"><input type="checkbox" ${p.options[o.key] ? 'checked' : ''} onchange="toggleOption(${p.id},'${o.key}')"><label class="opt-name">${o.short ? '-' + o.short : ''}</label><label class="opt-desc">${o.desc}</label></label>`;

  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:12px">' + shorten(p.source) + '</span>' : '<span style="color:var(--text2);font-size:12px">未設定</span>'}
        <span style="color:var(--text2)">→</span>
        ${p.target ? '<span style="color:var(--text2);font-size:12px">' + shorten(p.target) + '</span>' : '<span style="color:var(--text2);font-size:12px">未設定</span>'}
      </div>
      <div style="display:flex;align-items:center;gap:8px">
        ${p.schedule.enabled ? '<span class="schedule-badge">' + getScheduleDesc(p) + '</span>' : ''}
        <span class="pair-status ${p.status}">${statusLabel}</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="exclude-row">
        <label>除外パターン(1行1パターン)</label>
        <textarea placeholder="*.log&#10;.git/&#10;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:12px;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:12px">
          <div class="schedule-row">
            <label>パターン</label>
            <select class="schedule-select" onchange="setSchedulePattern(${p.id},this.value)">
              ${SCHEDULES.map((s,i) => '<option value="' + i + '"' + (p.schedule.pattern === i ? ' selected' : '') + '>' + s.name + '</option>').join('')}
            </select>
          </div>
          <div class="schedule-row">
            <label>時刻</label>
            <input type="time" value="${SCHEDULES[p.schedule.pattern].time}" onchange="setScheduleTime(${p.id},this.value)">
          </div>
          <div class="schedule-row">
            <label>曜日</label>
            <div class="days">
              ${DAY_NAMES.map((d,i) => '<div class="day-btn' + (SCHEDULES[p.schedule.pattern].days.includes(i) ? ' active' : '') + '" onclick="toggleScheduleDay(${p.id},' + i + ')">' + d + '</div>').join('')}
            </div>
          </div>
          <div class="schedule-desc">${getScheduleDesc(p)}</div>
        </div>
      </div>
      <div class="pair-actions">
        <div class="pair-actions-left">
          <button class="btn btn-accent" onclick="runPairForce(${p.id})" ${p.status === 'running' ? 'disabled' : ''}>▶ 実行</button>
          <button class="btn" onclick="runPairDry(${p.id})">🔍 ドライラン</button>
        </div>
        <button class="btn btn-danger btn-sm" onclick="removePair(${p.id})">🗑 削除</button>
      </div>
    </div>
  </div>`;
}

function runPairDry(id) {
  const p = pairs.find(x => x.id === id);
  if (p) { p.dryRun = true; runPair(id); }
}

function runPairForce(id) {
  const p = pairs.find(x => x.id === id);
  if (p) { p.dryRun = false; render(); 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, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function exportConfig() {
  const data = {
    pairs: pairs.map(p => {
      const clean = { ...p };
      delete clean.status;
      delete clean.collapsed;
      return clean;
    }),
    schedules: SCHEDULES,
  };
  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;
        if (data.schedules) {
          data.schedules.forEach((s, i) => {
            if (SCHEDULES[i]) {
              SCHEDULES[i].time = s.time;
              SCHEDULES[i].days = s.days;
            }
          });
        }
        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('スケジュールをcrontabに登録しました', 'success');
    } else {
      notify('登録エラー: ' + (data.error || '不明'), 'error');
    }
  } catch (e) {
    notify('通信エラー: ' + e.message, 'error');
  }
}

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"

インストール基本情報

bash /opt/script/install-rsyncgui1.sh

必要なパッケージ

  • Python 3
  • rsync
  • sshpass(SSHパスワード認証時)
  • openssh-client

起動・停止

# サービス開始
systemctl start rsyncgui

# 停止
systemctl stop rsyncgui

# 再起動
systemctl restart rsyncgui

# 状態確認
systemctl status rsyncgui

# ログ確認
journalctl -u rsyncgui -f

アクセス

ブラウザで http://localhost:3326 にアクセス。

画面構成

項目説明
+ ペア追加ソース→ターゲットのペアを追加
📥 エクスポート全設定をJSONファイルとしてダウンロード
📤 インポートJSONファイルから設定を復元
⏰ スケジュール登録crontabにスケジュールを登録
▶ 全実行全ペアを一括実行

ペア設定

フォルダ指定

  • ソースフォルダ: コピー元のローカルパス
  • ターゲットフォルダ: コピー先のパス
  • 📁ボタンでフォルダ選択ダイアログが開く
  • パスは直接入力も可能

モード

モード説明
📋 Copyファイルをコピー(-r
🔄 Sync同期(-avz、デフォルト推奨)

SSH設定

「SSH リモート接続」トグルをONでSSH設定が表示される。

項目説明
ホスト名 / IPリモートホスト名またはIPアドレス
ポートSSHポート(デフォルト: 22)
ユーザー名リモートユーザー
パスワードパスワード認証の場合(sshpass使用)
SSH秘密鍵パス鍵認証の場合の鍵ファイルパス

rsyncオプション

オプション短縮形説明
archive-aアーカイブモード(権限・タイムスタンプ保持)
verbose-v詳細出力
compress-z転送時圧縮
recursive-rサブディレクトリ再帰
times-tタイムスタンプ維持
perms-pパーミッション維持
owner-oオーナー情報維持
group-gグループ情報維持
links-lシンボリックリンクを複製
hard-links-Hハードリンクを複製
deleteターゲット側の不要ファイル削除
partial-P中断ファイルを保持
progress進捗表示
human-readable-h人間が読めるサイズ表示
checksum-cチェックサムで比較
itemize-changes-i変更詳細表示
stats統計情報表示
ignore-existing既存ファイルをスキップ
update-u新しいファイルのみ転送
backup-b上書き前にバックアップ

除外パターン

除外したいファイル・フォルダを1行1パターンで指定。例:

*.log
.git/
node_modules/

実行ボタン

ボタン説明
▶ 実行本番実行(dry-runなし)
🔍 ドライラン実行計画のみ確認(実際の転送なし)

スケジュール設定

3パターンのスケジュールから選択可能。各ペアに個別に設定する。

パターンデフォルト設定
設定1毎日 02:00
設定2月・水・金 12:00
設定3土・日 23:00
  • 時刻と曜日はカスタマイズ可能
  • 「⏰ スケジュール登録」ボタンでcrontabに反映
  • スケジュールOFFにして登録ボタンを押すと、crontabから削除される

エクスポート・インポート

  • エクスポート: 全ペア設定をJSONファイルに保存
  • インポート: JSONファイルから設定を復元

対象: ソース・ターゲット・SSH設定・rsyncオプション・除外パターン・スケジュール設定

ファイル構成

/opt/rsyncgui/
├── server.py          # Python HTTPサーバー
├── public/
│   └── index.html     # Web UI
├── config.json        # 設定保存ファイル
└── start.sh           # 手動起動スクリプト

/etc/systemd/system/
└── rsyncgui.service   # systemdサービス定義

トラブルシューティング

SSH接続エラー

  1. ターミナルで ssh user@host が動作することを確認
  2. パスワード認証の場合、sshpassがインストールされていることを確認
  3. ポート番号が正しいことを確認

サービスが起動しない

# ログ確認
journalctl -u rsyncgui -n 50

# 手動起動してエラー確認
cd /opt/rsyncgui && python3 server.py

設定が反映されない

  • ページを再読み込み(F5)
  • 設定はブラウザに保持されない場合があるため、定期的にエクスポートすることを推奨
タイトルとURLをコピーしました